Guidance for AI agents working in this repo. (Symlinked to CLAUDE.md / AGENTS.md / QWEN.md / GEMINI.md — see "Context for All AI Assistants" below.)
Hanzo Engine is Hanzo AI's native inference engine: a fast, multimodal Rust engine (LLM + vision + audio + speech + image + embeddings) that serves the /v1 inference plane of the Open AI Cloud. One hanzo binary (run, serve, bench, tune) + a Rust crate (hanzo) + a Python package (hanzo).
Hanzo Engine is a fork, not an independent implementation, and the history it was forked with is
retained in full. NOTICE records the project, the history and every vendored source with its licence;
tools/upstream-map.sh maps the original crate paths onto ours (the crate rename rewrote commit hashes
from 2024-02-27 onward, so SHAs stop matching there even where the content is unchanged). A renamed file
is still the original project's file.
The licence posture, and why each piece exists:
LICENSEis MIT and names two holders, the original author first, thenHanzo AI, Inc.MIT §2 is satisfied by retaining that copyright line, so the first line is load-bearing. Never delete it. This is not hypothetical:963f0ff2b(2026-06-01) replaced it with a Hanzo-only line as part of a brand sweep, and3b993189erestored it 45 days later. Releases cut in that window went out non-compliant. Brand sweeps skipLICENSE,LICENSE-APACHEandNOTICE.LICENSE-APACHEis for vendored GPU kernels. The tree bundles Apache-2.0 CUDA/Metal source with per-file headers intact; a ported file's leading header (copyright, licence, where it was adapted from) stays byte-for-byte, and the comments below it describe our code. Apache-2.0 §4(d) makesNOTICEan actual distribution obligation — unlike MIT, retaining the licence text alone is not enough.NOTICEmust ship with any binary or crate we distribute.- The tensor framework is our fork, hanzoai/ml (
hanzo-ml,hanzo-nn,hanzo-transformers,hanzo-flash-attn{,-v3},hanzo-metal-kernels), consumed as crates.io registry deps under MIT. Attribution for it lives in that repo's LICENSE files and NOTICE — don't restate them here. - Workspace
license = "MIT"matches the original.hanzo-routerdeclaresMIT OR Apache-2.0; that is deliberate and safe — it is first-party routing code with no forked dependency.
The rule that generalises: license = in a manifest is a claim; the LICENSE files are the fact. A fork cannot relicense, and a find-and-replace across manifests or copyright headers is a licensing change, not a chore.
The tree names only itself. Comments, docs, tests, logs, CLI help and identifiers say what our code
does, or the math; a reference value is "the reference value from the published config" or "the
recorded reference output (the fixture)", never another engine's name. Comparisons with other engines
are not recorded here. What legitimately carries a third-party name, and stays: LICENSE,
LICENSE-APACHE, NOTICE and tools/upstream-map.sh; the leading copyright/licence/provenance header of
a ported file; dependency names in Cargo.toml; data identifiers (Hugging Face repo IDs, file names and
fixture keys the code reads, GGUF metadata keys and arch strings, executable names a guard matches on
a host); model and format names (Llama, Qwen, Mistral, GGUF, safetensors, ggml quant types); the
OpenAI and Anthropic API names, which are our wire protocol. Rewrite by reading each hit: a blind sed
once stripped third-party licence notices and renamed real packages into 404s.
Upstream v0.9.3 merges through the crate rename, not around it. A plain git merge grafts: rename
detection cannot pair upstream's core crate directory with hanzo-engine/ once contents diverge, so
upstream's tree lands beside ours (1,622 duplicates). What works:
- Branch
upstream-renamed: one commit renames upstream's crates, paths and identifiers onto our layout. - Merge it, then for each conflicted path run
git merge-file --diff3with the base blob read from the pre-rename path at the fork point0a14af4a. The 327 conflicts become ordinary three-way hunks, resolved in slices: models, pipeline core, attention and quant, server, root. tools/merge-audit.shchecks that every upstream-added file landed and is declared, and that our-only subsystems (router, NVFP4, ROCm, Vulkan) survived.- Files main renamed by hand are invisible to the pairing and need their own three-way: upstream's
server builder is our
server.rs, its router builder ourrouter.rs, its Python stub ourhanzo.pyi.git diff -M --name-status 0a14af4a mainlists them.
Decisions in the merge:
- Upstream's types where both sides had one:
Nvfp4Layer(our vecmat/WMMA kernels and the CUTLASS W4A4 prefill with our activation quantizer; no cuTile on the NVFP4 path),crate::gdn(our ROCm, Vulkan, Metal and CUDA backends inside it), the positions-only rotary API, hybrid-cache checkpoint lanes, speculative decoding (MTP, DFlash, rollback, plus our DSpark and prompt lookup), the CPU attention backend, FlashInfer metadata (GQA groups 1..=8 and 16). - Ours kept: GGUF quantized models with ROCm/Vulkan/CUDA decode graphs, ROCm and Vulkan attention fast paths, key-chunked eager attention for long prefill, the router, the license check.
- hanzo-ml takes the framework's newer APIs (
barrier_pool,indexed_gemv,gemv_fused_shared_lhs, quantizedembedding, safetensors 0.8), synced from its main plus35d7ae7c, the rev upstream pins. - HTTP requests no longer choose filesystem locations: training adapters go to
~/.cache/hanzo/adapters/{id}/{name}, calibration imatrices to~/.cache/hanzo/cimatrix/{name}. - Dropped: upstream
releases/andCITATION.cff, the GitHub workflows and Dockerfiles main had removed, and per-slotseqlen_offsets(upstream's pool zeroes a slot on allocation). Thecutilefeature stays optional; it needs CUDA 13.2+, which spark does not have yet. - Known loss: Qwen2.5-VL video timing in MRoPE (
second_per_grid_ts), to re-add inside upstream'scompute_rope_index.
Measured in a restored window on evo's serving replica (Qwen3.8-27B Q6_K, 3 runs per point): 53 s to first token at 12.5K prompt tokens, and 22.9 s with four clients at 1.3K. That is what the fleet feels when evo serves alone during a spark window. It is the argument for keeping both hosts up and for the router preferring spark on long prompts.
Measured while porting upstream's Anthropic surface. Of the four things that break Claude Code against this cluster, upstream fixes none:
tool_referenceblocks (what ToolSearch results are) — 400.documentblocks in tool results — 400.- MCP image shapes (
data+mimeType) — base64 dumped into the prompt as text. - Server-side
tool_choice— half-fixed: it recognisesweb_search_*andcode_execution_*only, sobash_*,text_editor_*andcomputer_*still 400 for want of a schema.
Its count_tokens restructuring is the real fix and we took it.
Where each is handled matters. hanzo-engine now renders tool_reference natively, but the pool's
replicas on spark and evo do not run hanzo-engine yet, so hanzo-router's rewrite is what actually keeps
Claude Code working today and must stay until the engine serves the fleet.
Deliberately not ported, because the chains run to ~22k lines and would tear out our router work:
upstream's error-codes, skills, /v1/responses and OpenAI tool-surface commits (ApiError,
skill_store, OpenAiToolSurface, StreamOutcomeHandle), the tool-call lifecycle and the forced-decode
half of required, and the agentic file-surfacing commits, which live in a shell.rs we do not have.
- spark driver 580 caps CUDA at 13.0, so cuTile (needs 13.2+) could not build. CUDA 13.4.1 now sits
beside 13.0;
/usr/local/cudastill points at 13.0 because spark's serving stack pins that path. Build withCUDA_HOME=/usr/local/cuda-13.4, run withLD_LIBRARY_PATH=/usr/local/cuda-13.4/compat:...lib64, which is a newer user-mode driver on the old kernel module. No driver upgrade, no reboot. - A CUDA build script pins the compiler first, then probes the version with that same path.
cudaforgeresolvesNVCC→ PATH →CUDA_HOME, so PATH wins by default and a host with two toolkits compiles with one and gates features on the other. hanzo-quant, hanzo-engine, hanzo-paged-attn and hanzo-flash-attn each carry the same pin, and the four must agree. - hanzo-ml carries the newer framework APIs the merge needs (
barrier_pool,indexed_gemv,gemv_fused_shared_lhs, quantizedembedding, safetensors 0.8,GgmlDType::block_align). Storage locks moved to parking_lot, sostorage_and_layout()returnsStorageRef, not a std guard. tools/merge-audit.shgates the merge. It fails unless every upstream-added file is present and compiled, deliberately removed with a reason intools/merge-audit-removed.txt, or renamed to a path that exists; a removal entry whose file reappears is itself a failure. It also knows thatmain.rs,include!and#[path]compile a file, not onlymod.
- One image:
hanzo.ymldeclaresghcr.io/hanzoai/enginefromDockerfile, linux/amd64, holdinghanzo-serveralone (the deployment's command). A release is the version inCargo.tomland the 18 workspace entries ofCargo.lock, arelease: hanzo-engine Xcommit and avXtag. - Nothing builds on a push or a tag: GitHub Actions is off on hanzoai/engine
(
gh api repos/hanzoai/engine/actions/permissionsanswersenabled: false), andcargo fmt --checkis red across the workspace on main. The image is asked of the door directly, as org hanzo:POST https://api.hanzo.ai/v1/buildwith{repo, sha, image: ghcr.io/hanzoai/engine:X, tags: [vX], dockerfile: Dockerfile, context: ., platforms: [linux/amd64]}(bearerhanzo --as hanzo auth token,X-Org-Id: hanzo), thenGET /v1/build/<id>. A 429 is the org's concurrent-build limit. - The door's 30 minutes include the queue. Its Jobs (2 CPU requested, 8 limit, 16 GiB) share the
three 8-vCPU CI nodes with the forge's runners and can wait past the deadline unscheduled; a Job still
running at the deadline keeps running and pushes while the row reads
failed, so ghcr is the record. A cold build compiled in 16m50s on a contended CI node (1.7.103); 1.7.104 built and pushed in 8 minutes. - Three defects stopped every image after 1.7.53, each found by a build that died: the Dockerfile
capped cargo at two jobs (1.7.102 lets it use the Job's CPUs);
hanzo-zen5/zen5-engine-srcwas a gitlink no.gitmodulesnamed, and BuildKit's submodule update failed before the Dockerfile was read (1.7.103 drops it); the runtime stage copiedhanzo-bench, which has had no binary since b2832f1c5 (1.7.104 ships hanzo-server alone). Dockerfile.cuda and Dockerfile.rocm still build and copyhanzo-bench. - Digest:
docker-content-digestofghcr.io/v2/hanzoai/engine/manifests/Xwith a pull token fromghcr.io/token?scope=repository:hanzoai/engine:pull(basic auth withgh auth token).
- A real implementation repo (native inference) — NOT an SDK, NOT a discovery/wrapper repo. It serves the
/v1API that the cloud SDK family calls. - Cloud SDKs (Python flagship
hanzoai/python-sdk;@hanzo/ai; Go/Rust/C++/Swift/Kotlin) are the clients. SDK completeness: Python > Rust > C++ > Go. - DRY: one impl, one place. Keep engine internals here; link out, never duplicate.
- Whole-model truth:
~/work/hanzo/SDK-ARCHITECTURE.md.
- Hanzo is a full AI cloud / inference engine — never an "LLM gateway" or "OpenAI-compatible proxy", and never positioned against a proxy product. Speaking the OpenAI / Anthropic wire formats is a feature, not the identity.
- HTTP paths are
/v1/...only — never an/api/prefix. The CLI UI's own calls are/ui/v1/*(hanzo-cli/src/ui/mod.rs,webui/src/lib/services/api.ts), web-chat's are/v1/*, and hanzo-router serves no/api/path.hanzo.yml'sv1-onlygate greps for a regression;route_registrytests the server routes. - Zen is our own model family (zen-eco, zen-ultra, Zen5, …); never surface upstream model names as the brand in public docs.
- Crisp, modern, developer-first voice; no emoji-spam.
- Build / run: see Essential Commands. Agent rules: see Rules for AI Assistants. Symlinks: see Context for All AI Assistants.
- Core crates:
hanzo-engine/(inference library; canonicalhanzo_engine::{infer,embed}, consumed by EVM precompiles0x0201/0x0202) ·hanzo-server/(binaryhanzo-server, released ashanzoai) +hanzo-server-core/src/routes.rs(/v1HTTP routing) ·hanzo-cli/(binaryhanzo-engine) ·hanzo-pyo3/(Python) ·hanzo/(Rust API) ·hanzo-quant/·hanzo-paged-attn/·hanzo-vision/·hanzo-audio/·hanzo-llm-mcp/.
hanzo-engine/ is a library; the binaries are built by other crates.
-
Library —
hanzo_engine::*(the canonical inference API for the Hanzo stack):InferenceEnginetrait:fn infer(&self, model_id: &[u8;32], prompt: &[u8]) -> Result<Vec<u8>, EngineError>EmbeddingEnginetrait:fn embed(&self, dim: usize, text: &[u8]) -> Result<Vec<f32>, EngineError>- Process-wide registry (
OnceLock-backed):register_inference_engine,register_embedding_engine,infer,embed - The engine-backed implementation (
hanzo-engine) (handles HF repos and local paths; derivesmodel_idas SHA-256 of source) - Consumers: hanzo-vm precompiles
0x0201(AI inference) and0x0202(AI embedding) — they callhanzo_engine::infer/embedsynchronously through the registry - NOT the routing/pricing crate. The Hamiltonian-Hidden-Markov MarketMaker lives in
hanzo-hmm(~/work/hanzo/net/hanzo-hmm) and prices heterogeneous compute; the EVM precompiles depend onhanzo-engine, not onhanzo-hmm.
-
Binaries (names from
cargo metadata):hanzo-engine, packagehanzo-cli/:serve,run,tune,bench,from-config,doctor. Linkshanzo-server-corein process.install.shinstalls it withcargo install --git ... hanzo-cli.hanzo-server, packagehanzo-server/: the older model-subcommand server. Release CI packages it ashanzoai(.github/workflows/release.yml), and it logs a deprecation warning naming hanzo-cli.- Neither binary is
hanzo; that name belongs to the Hanzo CLI (hanzoai/cli), whosehanzo engine serve MODELrunshanzo-engine serve -m MODEL.
Hanzo Engine is a Rust workspace containing:
- All upstream hanzo workspace members (hanzo-engine, hanzo-server, hanzo, hanzo-llm-mcp, …)
- hanzo-engine/ — library: canonical Hanzo inference + embedding API
- Local hanzo-ml fork at
../ml/hanzo-{ml,nn,flash-attn,metal-kernels}overrides upstream'shanzo-ml-*crates via[workspace.dependencies]path overrides
The engine provides comprehensive LLM inference with support for text, multimodal (incl. video), image generation, speech, and embeddings through multiple APIs (Rust, Python, OpenAI HTTP, MCP).
Three sibling crates live alongside hanzo-engine/ and share the workspace's pinned tensor framework (hanzo-ml 0.9.2-alpha.2, half 2.7.1) — single source of truth:
hanzo-federation/— Vendor-neutral federated-training transport, scheduler, and coordinator. Pure-Rust port ofzen/gym/src/gym/distributedwith byte-identical wire format (canonical BF16 delta blob + HMAC-SHA256 auth). Public surface:Coordinator::new(lab).serve(addr),Worker::new(cfg).run(step, params, apply, data),TransportClient. Built on the engine workspace's axum 0.8 / reqwest 0.13 / tower-http 0.6.hanzo-quant/— Pure-Rust quantization:BitDelta(1-bit signs + per-tensor scale, ~32x),DeltaQuant(INT2/4/8 grouped symmetric),DeltaSoup(Byzantine-robust trim-mean aggregation). Uses the workspacehanzo-mlso no version split.hanzo-zen5/— Zen5 inference adapter. Defaultffifeature wraps the vendoredzen5-engineC runtime (Metal / CUDA / CPU);nativefeature swaps in a hanzo-ml DeepSeek V4 Flash scaffold using workspacehanzo-ml/hanzo-nnplus the localhanzo-transformersfork.
Composition with hanzo-engine: hanzo-federation ships the cross-host training fabric; hanzo-quant produces the delta blobs that travel on it; hanzo-zen5 plugs zen5 model variants into the InferenceEngine trait registry alongside the engine-backed implementation. hanzod (in ~/work/hanzo/node) pulls all four via workspace paths (hanzo-federation = { path = "../engine/hanzo-federation" }, etc.).
The root Cargo.toml is a virtual workspace, so every build names a package.
# Check the library compiles
cargo check -p hanzo-engine --no-default-features --features metal
# CLI binary `hanzo-engine` (what install.sh installs)
cargo install --locked --path hanzo-cli --features metal # macOS
cargo install --locked --path hanzo-cli --features cuda-full # Linux, CUDA + flash-attn + cuDNN
cargo install --locked --path hanzo-cli --features rocm # Linux, AMD
# Release server binary target/release/hanzo-server; release.yml packages it as `hanzoai`
cargo build --release -p hanzo-server --no-default-features --features metalAPUs expose a tiny dedicated-VRAM carve-out (HIP reports ~1 GB) alongside the
large unified GTT pool (~the whole system RAM). A raw hipMalloc targets the
1 GB carve-out and OOMs immediately on any real model. Run with unified memory:
HSA_XNACK=1 LD_LIBRARY_PATH=/opt/rocm/lib \
hanzo-server --port 8080 gguf -m <repo> -f <file.gguf>HSA_XNACK=1 lets managed allocations page into GTT so the full unified pool is
usable. (The build already matches the fused qk-norm-rope cos/sin cache to the
activation dtype; without that fix every GGUF MoE decode step errors on gfx1151.)
# Run core tests
cargo test -p hanzo-engine -p hanzo-quant -p hanzo-vision
# Format code (uses rustfmt, ruff, clang-format)
make fmt
# Check formatting
cargo fmt --all -- --check
# Run clippy
cargo clippy --workspace --tests --examples -- -D warnings# Release binary. No default port; --serve-ip defaults to 0.0.0.0.
hanzoai --serve-ip 127.0.0.1 --port 1234 run -m <model_id>
hanzoai --serve-ip 127.0.0.1 --port 1234 multi-model --config models.json # several models, one port
hanzoai --port 1234 gguf -m <repo> -f <file.gguf>
hanzoai -i plain -m <model_id> # interactive, no server
# CLI binary. serve defaults to 0.0.0.0:1234 and advertises over mDNS unless --no-advertise.
hanzo-engine serve -m <model_id>
hanzo-engine run -m <model_id> -i "prompt"When integrating a new model, make sure it respects all of the varbuilder .pp calls. In Hanzo, a VarBuilder maintains an internal path vector that acts like a “current working directory” for model weights; every call to pp("sub") (alias for push_prefix) clones the builder and appends sub, so successive calls accumulate a dotted prefix such as transformer.h.0 while leaving the original builder untouched . When you eventually call get(...), Hanzo joins that prefix with the tensor name (prefix + "." + name) and looks it up in the checkpoint backend, producing keys that exactly match the dot-separated names emitted by PyTorch’s state_dict/named_parameters, which means PyTorch-trained weights can be loaded without any renaming . This lets you recreate the PyTorch module tree in Rust by “walking” it: e.g. vb.pp("word_embeddings") grabs word_embeddings., while a chain like vb.pp("encoder").pp("layers").pp(i.to_string()) targets keys such as encoder.layers.0., exactly as shown in community tutorials porting Transformers models to Hanzo . As one maintainer put it, the prefix system lets you “cd” around the parameter hierarchy, giving a lightweight namespace mechanism that keeps Hanzo fully compatible with PyTorch naming conventions while remaining ergonomic to use.
You should also look for a model.safetensors.index.json file for the model at hand to verify correct structure.
hanzo-engine/- Hanzo's custom inference server and CLI- Custom CLI with model management commands
- OpenAI-compatible HTTP server (port 36900)
- Status: Compiles successfully with Metal backend (macOS)
hanzo-engine/- Core inference engine, model implementations, pipelineshanzo-server/- CLI binary entry pointhanzo-server-core/- HTTP server routing, OpenAI API implementationhanzo-pyo3/- Python bindings (PyO3)hanzo/- High-level Rust APIhanzo-vision/- Vision model supporthanzo-quant/- Quantization implementations (ISQ, GGUF, GPTQ, etc.)hanzo-paged-attn/- PagedAttention implementationhanzo-audio/- Audio processinghanzo-llm-mcp/- Model Context Protocol clienthanzo-bench/- Benchmarking tools
-
Pipeline Architecture: All models implement the
Pipelinetrait inhanzo-engine/src/pipeline/mod.rs. Different model types (Plain, GGUF, GGML, Vision) have their own pipeline implementations. -
Model Loading: Models are loaded through
Loadertraits that handle different formats and quantizations. Seehanzo-engine/src/loader.rs. -
Request Handling: The server uses message passing with
Hanzostruct managing a background thread pool. Requests flow throughhanzo-engine/src/engine/mod.rs. -
Device Management: Automatic and manual device mapping for multi-GPU setups handled in
hanzo-engine/src/device_map.rs. -
Decode Graphs (CUDA/ROCm): Steady-state single-token decode replays a captured GPU graph instead of relaunching hundreds of kernels/token (
pipeline/cuda_graph.rs,pipeline/rocm_graph.rs). Wired for safetensors inNormalPipelineand for GGUF inGGUFPipeline(pipeline/gguf.rs). Toggle withCUDA_GRAPHS=0/ROCM_GRAPHS=0. ELIGIBILITY INVARIANT (model_supports_decode_graph): only variants whose decode RoPE reads the devicemetadata.rope_positionsbuffer (Qwen3, Qwen3MoE) are graph-safe. Host-offset RoPE (RotaryEmbedding::forward->selected_rope_cache->cos.narrow, used by Llama/Phi2/Qwen2) and per-forward mRoPE built viaTensor::from_vec(Qwen35compute_text_mrope) FREEZE the position at capture and emit garbage on replay. GB10 decode wins: 0.6B +32%, 8B +7%, 30B-A3B MoE +25%, all byte-identical to eager.
When adding new model architectures:
- Implement the model in
hanzo-engine/src/models/ - Add pipeline support in
hanzo-engine/src/pipeline/ - Update model detection in
hanzo-engine/src/pipeline/normal.rs - Add architecture enum variant in
hanzo-engine/src/lib.rs - Update CLI args in
hanzo-server/src/main.rs
When adding new quantization methods:
- Implement in
hanzo-quant/src/ - Add to quantization loading logic in pipelines
- Update documentation in
docs/QUANTIZATION.md
hanzo-engine serve --arch musetalk -m <bundle> loads a MuseTalk lip-sync/avatar model and
serves /v1/animate (alias /v1/video/lipsync): POST {model, visual, audio, fps?},
audio + visual in, dubbed mp4 out. A still image -> Portrait, a video -> Footage
(lip-sync); the pipeline picks the animator via accepts() and the handler muxes the
rendered frames + driving audio with ffmpeg.
-m is a self-contained MuseTalk bundle (a local dir or HF repo). All six sources
resolve as fixed safetensors sub-paths of the single model_id:
musetalkV15/musetalk.json
musetalkV15/unet.safetensors
sd-vae-ft-mse/config.json
sd-vae-ft-mse/diffusion_pytorch_model.safetensors
whisper/tiny.safetensors
s3fd.safetensors
Selection path: CLI --arch musetalk -> ModelType::Animation -> ModelSelected::Animation
-> AnimationLoader (AnimationLoaderType::MuseTalk) in pipeline/animation.rs. Runs on
CPU today (Device::Cpu); GPU is a separate effort. A .pth/.pt pickle still loads
(load_vb) but safetensors is canonical.
hanzo-engine/src/diffusion_models/oasis/ is a native port of Etched/Decart Oasis-500M
(MIT, code + weights Etched/oasis-500m): an action-conditioned frame-autoregressive
latent-diffusion Minecraft world model (a playable generative game engine).
vae.rs— ViT-VAE (vit-l-20): 20x20 conv patchify of a 360x640 frame -> ViT blocks -> 16-dim latent[16,18,32]. Attention is 2D pixel-axial RoPE; MLP is exact GELU.dit.rs— DiT-S/2 spatiotemporal DiT (16 blocks): adaLN spatial-attn + causal-temporal-attn, action conditioning viaexternal_condadded to the timestep embedding. tanh-GELU MLPs.rope.rs— interleaved (lucidrains adjacent-pair,is_gptx=false) axial RoPE. Spatial/VAE use pixel freqs, temporal uses lang freqs; freqs verified against the checkpoint.sampling.rs— diffusion-forcing rollout (sigmoid-beta schedule, per-frame noise, sliding window atMAX_FRAMES=32). Fully-hallucinated: only prompt frames are VAE-encoded, all subsequent frames are generated purely in latent space (no encoder in the loop).mod.rs—WorldModel::{load,encode_frames,generate,decode_frames}+ 25-keyACTION_KEYS.
Parity vs the torch reference (CPU f32, parity.rs, weight-gated on OASIS_DIR): VAE encode
latent cosine 1.000000, decode PSNR 59.73 dB, DiT single-step cosine 1.000000.
Gotcha: hanzo-ml's Linear only matmuls up to rank 4; the DiT keeps rank-5 [B,T,H,W,D] tensors,
so all its linears go through apply_linear (flatten leading dims -> matmul -> reshape).
Example: cargo run --release --example oasis_generate -- --frames 32 --steps 10 --out <dir>
(prompt image + action stream -> PNG frames; assemble with ffmpeg).
hanzo-engine/src/engine/mod.rs- Main engine orchestrationhanzo-engine/src/pipeline/mod.rs- Pipeline trait and common logichanzo-server-core/src/routes.rs- HTTP API endpointshanzo-pyo3/src/lib.rs- Python API entry pointhanzo/examples/- Usage examples for Rust API
You should always run cargo check/cargo c before returning to make sure code compiles. If code does not compile, only make edits.
Avoid returning TODOs.
- Unit tests are colocated with source files
- Integration tests in
tests/directories - Use
cargo test -p <crate>to test specific components - Python tests require building and installing the package first
- Feature Flags: Many features are gated behind Cargo features. Always check what features are needed for your use case.
- Device Indices: CUDA device selection uses 0-based indexing
- Chat Templates: Models may need specific chat templates - check
chat_templates/directory - Quantization: Different quantization methods have different hardware requirements
quantized_qwen3_5_moeis the Qwen3.5/3.6 (qwen3_5_moearch) hybrid model: Gated-DeltaNet (GDN) linear-attention layers interleaved with MoE. Registered end-to-end (GGUF archQwen35/Qwen35MoEinpipeline/gguf.rs,Qwen3_5MoeLoader, vision + text variants). GDN lives inhanzo-engine/src/models/gdn.rs.- Fused GDN kernel:
gdn.rsroutes the gated-delta-rule recurrence through ONE fused per-backend kernel (fused_gdn_gating_{cuda,metal}+ the fused scan) that drives Qwen3.5/3.6 to near-parity; the portable f32 ops-composed scan (gdn_step_scalar) is the reference and the CPU/Vulkan fallback. The fused-vs-portable A/B env knob was removed; bit-exact CPU gates (gdn_step_matches_reference_seq1,gdn_recurrence_cpu_shapes) are colocated ingdn.rsand compare the two paths directly. - hanzo-kernel (the portable kernel DSL, published 0.2.15) is a CODEGEN: one
#[kernel(targets(...))]Rust source lowers to CUDA/ROCm/Vulkan/Metal/WebGPU/CPU. Its op library (gdn,norm,rope,attnincl.sdpa_runtime,quant) and thefuseauto-fusion pass (fusion == composition of Map morphisms, onefused_interplaunch, zero intermediates) are all bit-exact on CPU.sdpa/sdpa_runtimeonline-softmax is the structural cure for the 8B flash-collapse. Seeml/LLM.mdfor the DSL and env-var details.
- The name is zen6 (upstream:
qwen4_exp): module, loader,--arch zen6, GGUF arch, fixtures, scripts. Upstream spellings are read in two places and never written:Qwen4ExpForConditionalGenerationinNormalLoaderType::from_causal_lm_name, and GGUFgeneral.architecture = qwen4exp, a parse alias onGGUFArchitecture::Zen6whoseqwen4exp.*keysContent::from_readersmoves underzen6.*. - Two doors, one model. The native safetensors checkpoint (
Zen6Loader,pipeline/loaders/normal_loaders.rs) and the same tower quantized upstream to GGUF (pipeline/gguf.rs) both reachmodels/quantized_zen6::ModelWeights, which is the ONLY place the layers are built. - The manifest is the contract.
models/zen6::manifestderives every tensor the text model reads — name, stored dtype, shape, and aRoleofDeviceorHost— from the config alone. The checkpoint's headers must equal it (apart from theDEFERREDprefixes), so a silent shape or dtype drift in a release checkpoint is a load failure naming the tensor, not a wrong logit.manifest_is_closed_under_configproves the derivation is total,loader_estimate_equals_allocationthat the loader reserves exactly what the manifest sums, anddeferrals_match_their_prefixesthat what is skipped is skipped on purpose. - What is where. Routed experts stay PACKED NVFP4 (E2M1 codes, one E4M3 scale per 16, an F32
global scale) and are read straight into the MoE kernel — nothing dequantizes 71 GB to walk it.
Dense side layers are block FP8 (E4M3, one F32 scale per 128x128). The rest is BF16. Layer 1's PLE
n-gram table is
Role::Host: it is hashed and looked up in an mmap'd buffer, never uploaded, and never charged to the KV budget (table_never_charged,hybrid_kv_charges_attention_and_index_only— only the full-attention layers and their QSA index entries are). - W4A4, matched to the server, not to an ideal.
hanzo-quant/src/quantize/reproduces the served activation quantizers bit-exactly — per-token/per-128-group FP8 in the three forms the compiled graph uses, and per-16 NVFP4 with the served fast-math formula. On CUDA that means the approximate instructions (div.full.f32,rcp.approx.ftz.f32) as inline PTX inkernels/quantize, built WITHOUT fast-math so only the places the server approximates are approximate. On CPU the same formulas use IEEE divide/reciprocal and differ in the last bit — which is why the CPU golden is a transcriptor and the GPU vectors are the arbiter. - The reference is the server, not the module source. The CPU golden,
hanzo-engine/tests/fixtures/zen6.safetensors, holds 24 tokens through the embedding, layers 0-3 (three gated-delta-nets, then attention), the final hyper-connection mixer and lm_head rows [0, 8192), every intermediate recorded. It rounds to bf16 ONLY where a served kernel stores (tl.storeor a custom kernel's output); between store points arithmetic is f32, because the compiler fuses away the eager casts. The served-kernel vectors (hanzo-engine/tests/fixtures/zen6_vectors.safetensors, andhanzo-quant/tests/fixtures/vllm_vectors.safetensorsfor the quantizers) are the served kernels run on the same inputs, so they measure the golden's gap. The recorders drive the reference server and are not in this tree; the fixtures are what the tests read. - Two facts about the checkpoint and the recorder. Layer 1's n-gram table is 128 shards in one
contiguous run, but the writer put 126 of them in numeric order and transposed one pair, so shard
100 and 101 sit between 97 and 98 — read each shard from its own header range, never from a stride.
And six served prefill vectors (
gdn.prefill.{conv,q,k,v,core,normed}) are every zero: the recorder'scausal_conv1d_fncall returned itsempty_likebuffer unwritten, so those stages have no served reference at all and their*.goldengaps in the vectors metadata are fiction.gandbetaare right, because they come fromaandb, not fromconv.vectors()refuses an all-zero stage by name. Re-recording those stages settles the call form. - Deferred.
mtp.(M3: multi-token prediction) is not read by the native door; the GGUF door drafts with the MTP head GGUF published beside the model (models/quantized_zen6_mtp.rs).
- Context: the checkpoint's own (the GGUF's
context_length, the config'smax_position_embeddings, 262,144 either way), times the YaRN factor when one is declared.Yarn::mropebuilds it for both doors from what each checkpoint says: the GGUF{arch}.rope.scaling.{type, factor, original_context_length, yarn_beta_fast, yarn_beta_slow}metadata, orrope_parameterswithrope_type: "yarn",factor,original_max_position_embeddings,beta_fast,beta_slow(the original defaults to the context either way). The released GGUF carries none, so--rope-yarn-factor F(GGUF serve/run) writes those keys into the loaded metadata (Content::extend_context) before any model reads them:--rope-yarn-factor 4= the card's static YaRN, 1,048,576 positions. The paged KV cache does not follow the factor: size it with--pa-context-len N(or--max-seq-len, the device-map demand). - YaRN on M-RoPE (
layers.rsYarn): the M-RoPE table spansfactortimes the original length and YaRN counts its rotations over THAT, so the ramp is frequencies 16..24 of 32, not the 14..22 a count over the original length gives. The attention factor0.1 ln F + 1multiplies cos and sin (f32, before the cast), so the indexer's roped dims carry it too. - QSA (
models/qsa.rs): per attention layer an index cache entry of 1 head x 128, K = compressed key at each group's last token, V = raw key per token. Paged: entries48 + linkv_layers()(IndexEntries, which ContentConfig and the native door'sKvLayers::indexedshare), read by layerl. Eager:HybridCacheConfig::side_kvcaches after the n-gram pool, batched/split/trimmed with the attention caches. Within the width (2051 tokens) the layer runs its dense attention unchanged; past it: device scores, host DET top-k, union-of-selections masked attention with the QSA kernel's numerics (qsa::softmax_pv, shared with the denseqsa::attend). One forward (Layer::forward->qsa::forward) serves both doors; only the loaders differ. - The two doors' indexers are the same tensors. The safetensors checkpoint stores one
self_attn.indexer.index_qk_proj[(4 + 1) x 128, 2560]bf16, the query heads' rows then the key head's, and Gemma normsq_layernorm/k_layernormasw; the GGUF splits the projection intoindexer.q_proj/k_projand stores the norms as1 + win f32. Checked on layer 3 of the released files: rows 0..512 are byte-equal toq_proj, rows 512..640 tok_proj, and the GGUF norms are exactly1 + w.qsa::Indexer::newloads the former,Indexer::from_ggufthe latter. - Validate on a GPU by comparing paged vs eager (
--paged-attn off) on the same long prompt: the paged path's only code not run by CPU tests is thereshape_and_cachewrite of the index entry.
A repeatable, measurement-first process. The scar tissue behind every step is in ml/LLM.md
("Vulkan PREFILL: the Q6_K lever, the roofline method..."). The rule: measure in-engine, never trust
a cache-warm microbench for a memory-bound kernel, and kill a lever the moment the engine says it lost.
- PROFILE a real forward:
VK_PROFILE_GPU=1 VK_PROFILE=1 hanzo-engine bench --prompt-len 512 --gen-len 0 ...-> per-op GPU time + record/submit/fence + pool fresh/hit counters. - RANK by roofline: achieved BW = bytes-moved / GPU-time vs device peak. Biggest (time-share x distance-from-roofline) = the target. (Q4_K GEMM: ~24 GB/s vs ~135 practical = the 18% that IS the gap.)
- PICK THE LEVER by what the roofline says, not by fashion: coalescing / occupancy / layout / reuse / fusion / vectorization. NOT tensor-core dtype (coopmat is measured-dead on gfx1151), NOT bigger tiles (occupancy collapse, measured 2.4x WORSE).
- IMPLEMENT in the DSL/shader; GATE BIT-EXACT -- a CPU oracle OR a CONTROLLED known-answer test (uniform weight -> exact k localized the Q6_K decode bug in minutes where random-vs-oracle only said "wrong").
- MEASURE IN-ENGINE (
--gen-len 0= the true cold-weight prefill floor). Env-gate A/B variants (VK_Q4K_BM, VK_Q6K_LEGACY, VK_Q4K_COOPMAT). Cache-warm microbench LIES for memory-bound kernels. - KEEP only if it wins in-engine; RECORD the dead ends (this session killed coopmat + bigger-BM).
Kernel-level LATENCY search over tile/occupancy configs (BM/BN/BK/threads/unroll), measured and cached
per (kernel, shape, device). Deterministic objective (ns), cheap trials -> a for-loop + cache, like
triton.autotune / cubecl autotune. The env A/B knobs (VK_Q4K_BM=64/128/256) are the manual version;
the DSL autotune automates the sweep so a hand-picked constant can't be a local optimum on another GPU.
- LOAD the GGUF; confirm COHERENCE with a known-answer generation (
hanzo-engine run -m <model> -i "The capital of France is"). - PROFILE prefill + decode -> the hot kernels for THIS model's quant mix + shapes.
- ROOFLINE-RANK -> the offenders. The classic miss: a quant type with NO tiled prefill path (Q6_K was 76% of prefill because attn_v/ffn_down are Q6_K in Q4_K_M and only had the column matvec).
- ENSURE every quant type in the model has a tiled prefill path (Q4_K, Q6_K done; Q5_K/Q8_0/IQ next).
- AUTOTUNE the tile configs for the model's shapes.
- GATE on the box's roofline:
hanzo-engine bench --prompt-len 512 --gen-len 128 auto -m <dir> -f <gguf>; prefill T/s against the compute roof, decode T/s against bandwidth / bytes-per-token. That fraction is the SOTA gate.
- Roofline dashboard (visual "find" UI: roofline plot + priority-ranked kernels + tuning log).
VK_PROFILE_GPU(per-op GPU ns),VK_PROFILE(record/submit/fence + pool counters),VK_ROOFLINE(planned: per-dispatch exact bytes/FLOPs joined to timestamps, auto-refreshing the dashboard).- The DSL end state (
ml/LLM.md"DSL MAGIC"): layout-as-value + a coalesced layout-aware loader + tiled-matmul + pluggable quant-decode + autotune, so every kernel inherits coalescing correct-by- construction and naive strided reads stop being possible.
- GGUF fast-path for Qwen3-VL: the dense text backbone (
qwen3vl, 2/4/8/32B) reusesquantized_qwen3::ModelWeights; the MoE text backbone (qwen3vlmoe, 30B-A3B / 235B-A22B) reusesquantized_qwen3_moe::ModelWeights. Wired asGGUFArchitecture::Qwen3Vl/Qwen3VlMoEingguf/mod.rs, dispatched inpipeline/gguf.rs, sized inutils/gguf_metadata.rs. No new modeling code — the VL text tower is structurally identical to Qwen3/Qwen3MoE (sameblk.*tensors, q/k-norm, GQA); metadata is read under the file's ownqwen3vl.*/qwen3vlmoe.*prefix (dynamicpath_prefix), sorope.freq_base, head dims and expert counts come straight from the file. - Why reuse is bit-correct for text: Qwen3-VL uses interleaved-MRoPE (sections [24,20,20]),
but for text-only tokens t==h==w, so every frequency band receives the same position and the
interleaved partition is a no-op — identical to the standard 1D RoPE already in
quantized_qwen3. Verified: Qwen3-VL-8B-Instruct Q4_K_M loads (36 layers, 151936-tok Qwen3 BPE), dispatches toModel::Qwen3, and decodes coherent text. - Image-blind (mmproj not wired): GGUF Qwen3-VL ships TWO files — the LLM gguf (loaded here)
and a separate
mmproj-*.ggufvision tower (general.architecture=clip,clip.projector_type=qwen3vl_merger,v.blk.*/mm.*/v.deepstack.*, DeepStack at vision layers 8/16/24).GGUFPipelineis text-only (modalities Text→Text, no image inputs processor), so the mmproj is NOT consumed. Full image support needs aclip/mmproj GGUF loader feeding the existingvision_models/qwen3_vltower PLUS a multimodal quantized-VL text forward (3D-MRoPE position_ids + DeepStack embed injection +<|image_pad|>merge) — a new subsystem, not a reuse ofquantized_qwen3::forward(whose signature carries only 1Dstart_offsetsand no DeepStack hook). See the safetensorsQwen3VLLoaderfor the reference multimodal path. - Decode graphs EXCLUDED for
qwen3vl/qwen3vlmoeGGUF: they dispatch to theQwen3/Qwen3MoEmodel variants, which are already graph-eligible for pure-text 1D RoPE; the VL archs run the same eager/graph text path. (Multimodal 3D-MRoPE, if added, would be position-dependent and must stay eager permodel_supports_decode_graph.)
The env-flag convention is BARE names (no HANZO_ brand prefix). The de-brand is COMPLETE: every runtime
knob is a bare name and the one-off dev A/B "fallback" toggles are DELETED (production always runs the
fast path; runtime HW auto-select is kept). One bare name per real knob. Canonical flags in perf_flags.rs:
CUDA_GRAPHS,ROCM_GRAPHS,METAL_GRAPHS,FLASHINFER_DECODE,FLASH_PREFILL-- all default ON, set=0to force the eager/unfused path. (HANZO_ROCM_FLASH_ATTNand its A/B toggle were already deleted; ROCm flash is always-on when applicable.)FLASH_PREFILLgatesusing_flash_attn()(CUDA).- De-branded runtime config:
KV_SPILL_DIR,KV_SPILL_BUDGET_MB,ISQ_SINGLETHREAD,VK_FUSED_QKNORM,MXFP4_DP4A,MN_LOCAL_WORLD_SIZE,NO_NCCL,FFI_MODELS,FFI_TOK_DIR,CUDA_FLASH_BF16,METAL_PRECOMPILE,ROCM_GFX_ARCH. - DELETED (one-off dev A/B toggles that forced the OLD/slow path):
HANZO_GDN_FUSED_FALLBACK,HANZO_ADD_RMSNORM_FALLBACK,HANZO_QK_NORM_ROPE_FALLBACK,HANZO_NO_MEMPOOL_FIX,SAMPLER_TRACE(plus the mlHANZO_Q*K_FALLBACK/HANZO_MOE_*_FALLBACK/HANZO_IQ*_FALLBACKfamily). The fused vs unfused decision is now the always-fast path; bit-exact oracle tests force the unfused/scalar leg via test-only programmatic setters (layers::set_force_unfused_qk_norm_rope,hanzo_ml::set_force_scalar_matvec), never env, never set in production. - License gate reads
HANZO_ENGINE_LICENSE_TOKEN/HANZO_ENGINE_LICENSE_FILE(seelicense.rs); these are product-namespaced identity vars, not perf flags, and stay branded by design -- the ONLY remainingHANZO_env names.
license.rsHANZO_LICENSE_PUBKEYis the public half of the production seed in Hanzo KMS at/engine/LICENSE_SIGNING_KEY(orghanzo, envprod). It is the only key the verifier trusts, in every profile; there is no dev key outside#[cfg(test)].- The engine never signs. hanzoai/licensing mints (
KMSSigner, same public key as itsPubkey), and hanzo-inc/cloud serves it at/v1/licensing. - The tests pin two tokens: one the production key signed (app "test", expired) and one the retired
development key signed, which must fail
BadSig. Rotating the key means a new KMS value, both public constants, and a fresh production token here and in hanzoai/licensing.
- Qwen 3 VL - Vision-language model support (#1657)
- Paged Attention Refactor - Simplified paged attention modules (#1654)
- Audio Processing - normalize, apply_fade, remove_dc_offset functions (#1572)
- Gemma 3N - Support for cases where q != (k=v) devices (#1653)
- No Busyloop Refactor - Improved engine efficiency (#1655)
See docs/ directory for detailed documentation on specific models and features.
- Path:
/v1/embeddings(hanzo-server-coreembeddings.rs) sends each input as its own sequence; the scheduler buckets sequences by exact length, so an embedding batch never holds padding and pooling reads every position (embedding_models/layers.rs). The model's token states pass through the sentence-transformers modules ofmodules.json(Pooling, Dense, Normalize). - Pooling reads both
1_Pooling/config.jsonlayouts: sentence-transformers 6 (embedding_dimension,pooling_modea mode or a list) and the olderpooling_mode_*flags. An unsupported setting (include_prompt: false, no mode) fails at load, not at the first request. dimensionskeeps the leading values and L2-normalizes them again (Matryoshka); 422 above the model's width. Meaningful for EmbeddingGemma 2 (768/512/256/128) and Qwen3 Embedding.- Revision: a multi-model config entry takes
revision(hanzo-server's JSON, server-coreModelConfig::revision), passed toload_model_from_hf; a commit pins the weights an index was built from. Single-model mode and the CLI's TOML config loadmain. - EmbeddingGemma 2 (
embedding_models/embedding_gemma2.rs, archembeddinggemma2, auto fromEmbeddingGemma2Model): the text backbone only (language_model.*; the vision and audio towers are never read). Gemma 4 layer: norms scale by the weight (not 1 + weight), v is RMS-normed with no scale, attention scale 1, full layers takeper_layer_config's head dim 512 and one KV head, sliding layers see|i - j| <= sliding_window(inclusive: the exclusive masker gets window + 1), a projection-only PLE (each layer reads its 512 rows ofple.per_layer_model_projection, shared norm), thenlayer_scalar, final norm andembedding_projection512 -> 768 per token. Context 8,192 (CONTEXT, the model card's). Never F16: its activations overflow it. Parity (2026-10-08, evo CPU F32, 64 texts in 14 languages incl. 4 of 250-1,256 tokens, vs sentence-transformers 6.1.0 / transformers 5.19.0): min cosine 0.9999993 at 768/512/256/128, token counts 64/64 equal. Qwen3-Embedding-0.6B on the same build: 0.9999956. - Production (universe
charts/app/values/hanzo/engine.yaml, 1.7.104): both models in one multi-model process per replica,files/engine/models.jsonpinning each revision; zen-embedding is Qwen3, zen-embedding-2 is EmbeddingGemma 2.kubectl top4.9-5.0 GiB a pod (3.2 GiB with Qwen3 alone). - Cost on 4 Zen 5 cores (evo cpus 4-7, F32, SciFact passages ~310 tokens, one request at a
time): EmbeddingGemma 2 1.26 texts/s (385 tok/s), 1.66 GiB loaded; Qwen3-Embedding-0.6B
0.68 texts/s (222 tok/s), 3.13 GiB; both in one multi-model process 4.69 GiB loaded, 4.93 GiB
peak, each at its own speed. torch on the same cores reads 2.8 and 0.9 texts/s. Profile
(EmbeddingGemma 2): gemm's AVX2 microkernel 35% (no AVX-512 kernel is selected), hanzo-ml's
BarrierPool spin 28%, and
MatMul::matmulcasts CPU attention to F16 (hanzo-quantlib.rs).
To pull latest changes from upstream:
# Fetch upstream changes
git fetch upstream
# View what's new
git log HEAD..upstream/master --oneline
# Merge upstream changes (creates merge commit)
git merge upstream/master
# Or rebase Hanzo changes on top of upstream
git rebase upstream/master
# After resolving conflicts, test build
cargo check --package hanzo-engine --no-default-features --features metalhanzo-router is the routing MECHANISM (registry, SLO gate, placement, dispatch).
It exposes one seam: the RoutePolicy trait --
route(&Request, &User, &Slo, &Registry) -> Route { model, level, modality, confidence }.
The rule-based Policy implements it as the cold-start fallback (placement-agnostic,
fixed low confidence). New vocabulary lives in registry (Modality, Level) and
route (Route, User, Slo, REFUSED_MODEL). Pre-existing API unchanged.
A learned policy implements RoutePolicy outside this repo; its fitted heads mount with
ROUTER_HEADS=<path> (Policy::load_heads).
The bodies are built in hanzo-server-core/src/observe.rs from plain values, the handlers in
handlers.rs.
- Nothing waits on the engine. The engine loop publishes to its
IntervalLogger: a heartbeat (Phaseidle/loop/prefill/decode plus when it entered it) at every iteration and step, and its load (running, waiting, oldest running sequence, paged KV occupancy) and prefix-cache counts at the top of every iteration, which is also the moment it goes idle, so at rest the numbers are exact. Readers only load atomics. /healthis 503 with the same body when the engine thread has exited or the loop overstayed its phase: 30 s between steps, 300 s in a decode step, 1800 s in a prefill. A long prefill is work, not a stall.- Counters are process-wide and only grow:
engine/ledger.rsstatics, fed once per finished sequence from itsUsageinfinish_or_add_toks_to_seq. An engine rebooted inside the process does not reset them;n > 1counts each choice. - Ledger line: INFO on target
hanzo_engine::engine::ledger, message startingserve_api:sobench-serving.py's regex matches it. Rounds are derived, not counted:n - 1 - draft_accepted(a verify round commits accepted + 1, the first token comes from prefill). Undrafted requests logbatchwith no rounds, which the bench's regex skips, as it skips serial requests. - Drafter names come from
SpeculativeAttachInfo::name:mtp,dflash, elsespec. Pipelines record the attach info (Pipeline::drafter);Hanzo::configcarries it.
The rules are pure functions in hanzo-server-core/src/controls.rs (checked in one fixed order by
chat_completion::parse_request, which Responses and Messages reuse), errors in error.rs, output shapes in shape.rs.
- Defaults are
hanzo serveflags (defaults.rs,Defaults::validatedat startup): a value the request sends always wins; nothing out of range is clamped, it is refused. - Temperature: request, else
--temperature, else greedy.0is greedy. - Budget:
max_tokens=max_completion_tokens=max_output_tokens(different values are a 400); default--max-tokens(completions--completion-max-tokens); over--max-tokens-capis a 400; <= 0 is 1. The think-block budget is the request's or--max-thinking-tokens, capped atmax_tokens - room(room--thinking-answer-room, elsemax(1024, 15%)), never 0. - Effort reaches the template through one function,
chat_template::template_effort: the top level isxhighon templates that name it (Qwen3.8 raises on anything else),highelsewhere. - Errors are OpenAI's envelope everywhere except
/v1/messages, which keeps Anthropic's; engineValidationErroris a 400, a declined forced call (FORCED_CALL_DECLINED) a 502, and an error after a stream started is adata: {"error":...}frame before[DONE]. - Streams: usage is on the stream only with
stream_options.include_usage(achoices: []chunk before[DONE]); the finish chunk carriestimings. Responses and Messages read usage from the engine's final chunk themselves, so framing lives only in the chat/completions SSE layer.
LLM.md is the canonical guide. CLAUDE.md and AGENTS.md are symlinks to it and
all three are tracked -- nothing here is gitignored, and the two aliases were real,
separately-drifting files until they were folded in.
- Record a significant discovery in this file. Never in a new summary file.
- Always
cargo checkbefore calling an integration complete, and if it does not compile, keep editing. Do not return TODOs.
Comments. Default to none. Add one only when the why is not obvious from the code: a hidden constraint, an invariant, a surprising edge case, a reference to a spec or an HF source. Never paraphrase the next line, never restate the function name, never narrate steps.
- Multi-line comments are discouraged in code, and are really only for documentation or where they are the best way to carry the information.
- One line each, up to ~120 cols. No multi-paragraph
///blocks, no bulleted lists in doc comments, no// === Section ===banners. - Terse and casual, and never explaining what the code directly below does.
- ASCII only unless instructed otherwise. No em-dashes, en-dashes, ellipsis
characters, smart quotes or box-drawing. Do not use
--....,"and'are fine. - Do not reference the current task, PR, fix or commit in a comment. That belongs in the PR description, and it rots as the codebase moves.
- Terse trailing annotations like
// already sent aboveare fine.
Magic values. Hoist durations, sizes and sentinels to named consts at the top
of the file. A sentinel that crosses a module boundary -- one place sets Some(0),
another checks for it -- must be a pub const, not a literal both sides happen to
share.
Function shape. At 6+ arguments, wrap the invariants in a small context struct
(DispatchCtx<'a>). Do not add error handling, fallbacks or validation for cases
that cannot occur; trust internal code and framework guarantees. No
backwards-compatibility shims unless asked.
Pull requests. Never include a "Test plan" section. Conventional commit style:
feat(crate):, fix(crate):, docs:. Update docs/src/content/docs/ and the
examples alongside a feature or a breaking change.
export HF_TOKEN=<token> # TESTS_HF_TOKEN for CI parity; some tests need it
cargo test -p hanzo-engine -p hanzo-quant -p hanzo-vision
cargo test --workspace # crates without tests are skipped
make fmt # rustfmt + ruff + clang-format
cargo fmt --all -- --check
cargo clippy --workspace --tests --examples -- -D warnings
cargo doc --workspace # rendered under target/doc/CI (.github/workflows/ci.yml) runs exactly: cargo check all targets, cargo test on the core crates, cargo fmt -- --check, cargo clippy -D warnings,
cargo doc, and crate-ci/typos. The docs site builds with cd docs && npm run build and deploys through .github/workflows/docs.yml.
Unit tests sit beside their source; integration tests live in tests/; cargo test -p <crate> narrows to one. Python tests need the package built and installed
first.
Paths are relative to docs/src/content/docs/. The CLI auto-detects model type, so
every one of these is hanzo-engine run -m <model>.
| Feature | Crate | Docs |
|---|---|---|
| Text inference | hanzo-engine, hanzo |
guides/customize/sampling.md, guides/agents/ |
| Multimodal | hanzo-vision |
explanation/multimodal-pipeline.md, reference/supported-models.md |
| Diffusion, speech | -- | reference/supported-models.md |
| Quantization and ISQ | hanzo-quant |
reference/quantization-types.md, explanation/quantization-tradeoffs.md; scripts/convert_awq_marlin.py |
| Paged attention | hanzo-paged-attn |
explanation/paged-attention.md, guides/perf/use-paged-attention.md |
| Adapters, LoRA, X-LoRA | -- | guides/customize/lora-adapters.md |
| AnyMoE | -- | guides/customize/anymoe.md |
Also outside the workspace: chat_templates/ (JSON/Jinja), examples/ (Rust,
Python, server, notebooks), scripts/.
torch.bucketize(right=True) needs Ok(i) => i + 1. Rust's
binary_search_by returns Ok(i) at the found position, which is bisect_left. For
bisect_right you must insert after equal elements. Err(i) => i is right either
way.
Causal Conv1d left padding is effective_kernel_size - stride, not
(kernel_size - 1) * dilation -- that is the total padding for the non-causal
case. kernel_size=3, stride=2, dilation=1 gives 1, not 2. Check against the HF
model's VoxtralRealtimeCausalConv1d or its equivalent.
Cross-verified research (the V4 config.json and arXiv paper, public reference implementations) + this session's GB10 (sm_121, 273 GB/s LPDDR5X, 128 GB unified) measurements. The binding constraint for batch-1 decode is MEMORY BANDWIDTH (~11 GB read/token); every win = fewer bytes/token or higher BW utilization. Compute-side FP4 tensor-core gains are IRRELEVANT at batch-1 (and broken on sm_121).
Found while onboarding the vision variant; it was present in both ports and neither's tests caught it, because they pinned the layer mode and never the window.
compress_ratios[il]selects a layer's compressor, not its context. Read as "no window", it gave the ratio-0 layers — and on the safetensors path the ratio-4Indexedlayers too, about half the model — unbounded attention, against a reference that windows every layer (its KV cache for those layers iswindow_sizelong).- Setting
SdpaParams.sliding_windowis not enough. Every shipped checkpoint carriesattn_sinkson all 43 layers, sorun_attentiondispatches to the sinks backend before the flash block. head_dim 512 misses every templated sinks kernel, and only the fused CUDA decode branch (q_len 1..=8, no mask) readswindow_size. CUDA prefill, Metal and CPU honour the mask alone, so the window must also arrive as a mask or a KV slice. - The old path was worse than unwindowed. With flash prefill on by default, CUDA prefill at
q_len > 8 produced
CausalFlash, which carries no tensor, and reached the unfused sinks path with no mask at all: non-causal attention.combine_with_maskneeds a base tensor, so DSA selection was silently disabled on the Indexed layers as well. Any earlier number from that path measured a different model. The GGUF path was unaffected — it has always usedCausalMaskConfig::gguf(). - Forcing a custom mask costs almost nothing here. Flash is unreachable while sinks preempt it, and the eager sinks path already materialises the scores, so the mask adds one L² plane beside 64·L². Passing the window to flash instead only pays off once the sinks backend can serve head_dim 512.
The root cause has exactly two instances, both DeepSeek-V4 and both fixed (deepseek4.rs,
deepseek4_mtp.rs); the GGUF window bug is a sibling, not the same cause. Counted with the right
predicate — sinks: Some(, since grepping sinks also matches sinks: None — only three files in the
crate load sinks at all, and the gpt-oss pair is safe because head_dim 64 hits a templated kernel that
applies causality itself.
Two instances is not an argument for vigilance. The bug needs three independent facts to line up: sinks
present, a head_dim outside every templated kernel's set, and a mask config that may return a
tensor-free variant (CausalFlash). No reviewer holds all three at once, which is why it survived tests
that pinned the layer mode. The fix is structural: run_attention should refuse a tensor-free mask when
it is about to dispatch to a backend that reads only the mask, so the combination fails at startup or at
compile time instead of silently producing bidirectional attention. Not attempted during the merge.
- NOT MLA. V4 replaced MLA with hybrid sparse attention (DSA), per-layer mix of:
SWA-128 (raw sliding window) · CSA (4×-compressed KV, lightning-indexer top-k 512/1024)
· HCA (128×-compressed KV, dense) · learned per-head attention sinks. The
head_dim:512 / kv_heads:1is a shared-K=V MQA head with partial RoPE-64 — compressed-KV MQA + sparse selection, not MLA up-projection decompression. - Weights ship FP8 (E4M3, 128×128 block, UE8M0 µscale) dense + native FP4 MoE experts, bf16 master. The model is W8A8 QAT-trained — it EXPECTS low-precision activations.
- MoE: 256(Flash)/384(Pro) routed, 6 active + 1 shared; gate = sqrtsoftplus + noaux_tc bias;
clamped SwiGLU (limit 10); 3 Hash-MoE bootstrap layers (
tid2eid). Residual = mHC (4-stream, 20-iter Sinkhorn doubly-stochastic mixing).
IQ2_XXS(ffn_gate/up_exps) · Q2_K(ffn_down_exps) · Q8_0(shared experts, all attn proj,
output) · F16(token_embd, router) · F32(norms, sinks) · I32(hash). q2≈81 GiB. imatrix REQUIRED
for IQ2_XXS (from real routed-expert activations). Q3=garbage, Q4 decode unstable (argmax
collapse from 43-layer error accumulation — the fix is hi-precision matmul accumulation).
V4-at-IQ2 sits at a repetition bifurcation: cumulative quant error across 43 layers tips it.
The fix is hi-precision matmul
accumulation, not higher activation precision. KEY MEASUREMENT: F32 is WORSE than bf16 here
— because the model is FP8-trained, so MORE precision moves AWAY from the trained operating point.
bit-exact = Q8_0/IQ2 kernels that accumulate in int32 dp4a / hi-precision, NOT go f32.
Current best config: bf16 carrier + native dp4a (CUDA_FAST_MMQ / V4 set_fast_mmq(true))
→ correct on short/medium (primary-colors, France, math, natural EOS); haiku-class long-hard
reasoning still degenerates (the accumulation-precision residual).
- Per-layer CUDA-graph capture + split-K F16/dp4a decode GEMV. 73→94% of roofline is
~19-25 t/s at 11 GB/token on 273 GB/s (we're at 3.5). Per-LAYER (resolve MoE routing host-side /
fixed expert capacity), because a data-dependent expert matmul breaks whole-graph capture.
cudarcgraph capture. This is the #1 perf item. - dp4a int8 (NOT tensor-core INT8/FP4) for decode GEMV. At N=1, WMMA wastes 15/16 slots (0.25×). FP4 tensor cores only help PREFILL (build sm_121a + CUDA 13 compute_121f). Decode = dp4a / F16 split-K.
- Bandwidth-optimal IQ2_XXS dequant→GEMV (wide 128/256-bit loads, in-register dequant,
L1::no_allocateon streamed expert weights,evict_laston reused activation, maxrregcount 32-45). The routed IQ2 experts dominate the 11 GB/token. Dequant bit-exact-tested against the GGUF block definitions. - Hi-precision accumulation in ALL quant matmuls (the haiku fix): int32 dp4a accumulate + verify IQ2 mmvq accumulates hi-precision. This is bit-exact correctness for hard prompts.
- Real V4 sparse attention (DSA: CSA top-k lightning-indexer + HCA + SWA-128 + sinks, FP8 KV) — two caches: latents + indexer keys. The compressor is wired (this session); the indexer top-k selection is the missing piece for >128 context (1M ctx ≈ 9.62 GiB/seq).
- Fuse MoE hot path (TopK gate+route, gate/up SwiGLU sharing activation load, RMSNorm+mul, compressor+RoPE+cache-insert).
- Unified-memory zero-copy (mmap GGUF GPU-addressable via Grace ATS;
cudaMemAdviseReadMostly/AccessedBy on resident, PreferredLocation(CPU)+AccessedBy(GPU) on cold experts; prefetch predicted experts). Enables SSD/CPU expert streaming.
- EAGLE-3 speculative (> native MTP) — accept ~2.5 vs MTP ~1.9. MANDATORY: batched verify (stream weights ONCE for all K draft tokens, fixed-shape CUDA graph) else net-negative single-stream (depth-1 MTP measures −21%). The MTP head (3.6 GiB gguf) is being loaded; wire it EAGLE-style, not depth-1.
- DeepSeek-OCR — a separate vision+V2 port (SAM/CLIP encoder + DeepEncoder + V2-MoE decoder). Scoped follow-on.
FP4/INT8 tensor cores for decode (batch-1 irrelevant, sm_121-broken) · whole-model CUDA graphs (MoE breaks them → per-layer) · native depth-1 MTP single-stream · DeepGEMM/FlashMLA drop-in (no sm_121 FP4 path mid-2026) · going F32 for "precision" (worse — model is FP8-trained).
sm_121: build sm_121a + CUDA 13.
Zero-training, quantization-agnostic draft that reuses 100% of the batched-verify engine
(driver/verifier/proposer) with NO verifier change. SpeculativeConfig::PromptLookup { ngram_min, ngram_max, gamma } -> speculative::prompt_lookup::PromptLookupProposer. The draft is the sequence's
OWN history: the tail n-gram (longest first, ngram_max..=ngram_min) -> its most-recent earlier
occurrence -> the <=gamma tokens that followed. Returns logits: None -> the verifier's exact-match
path -> provably lossless (greedy byte-identical on/off). Auto-gates: drafts only at batch <= 4.
- Attach in pipeline/{normal,gguf}.rs
attach_speculative(exempt from PagedAttention, like DSpark) ->draft_proposer. Ref: apoorvumang/prompt-lookup-decoding = HFprompt_lookup_num_tokens. - CPU/GGUF now works: the GGUF non-paged spec path was made GENERIC (was Deepseek-only) so any
normal-cache GGUF (qwen2/qwen3) drives
NormalSpeculativeCacheAccess. CLI--prompt-lookup-ngram <N>(reuses--gamma); server default-OFF (opt-in). Gate test: hanzo-cli/tests/prompt_lookup_lossless.rs (CPU). - MEASURED (zen-eco-4b Q4 GGUF, CPU, grounded code-repeat prompt): 85% acceptance (mean_accepted 6.83/8), byte-identical. ~6 target forwards emitted ~47 tokens vs 48 forwards without = ~8x fewer target forwards. Wall-clock ~break-even here (0.85-1.12x across runs) because CPU decode of this small model is COMPUTE-bound: a width-(1+gamma) verify forward costs ~(1+gamma)x a width-1 one, so spec-decode can only win on MEMORY-bound decode (GPU, or large models streaming weights once per forward -> wide verify ~free -> throughput ~= 1+accepted). Same regime law as the EAGLE/MTP note ("stream weights ONCE for all K").
- The head is the MTP GGUF published beside the model (
mtp-Qwen3.8-Flash-Next-{shared-,}Q8_0.gguf, archqwen4exp, read as zen6,block_count49,nextn_predict_layers1). One attention+MoE block atblk.48, behind the same 4-stream hyper-connections as the trunk. Entry:eh_proj [enorm(embed(x_{t+1})) | hnorm_s(h_t,s)]per stream, wherehis the trunk's wide streams before the output mixer. Exit: its own mixer (nextn.hc_head_*), thenlm_head. sharedfiles lacktoken_embd/outputand borrow the target's. Measured: the self-contained file'stoken_embdis byte-identical to UD-IQ4_XS's; itsoutputis Q8_0 where the target's is Q6_K.- The head's attention keeps a KV over the whole sequence. Every target forward writes its pairs'
K/V (the hook in
ModelWeights::forward), keyed by sequence id fromnote_forward_sequences; a draft step truncates at its own position, which drops rejected pairs. The Qwen3.5 and DeepSeek heads here keep only their chain (reset every round): at depth 1 that is one key, so attention returns V and the head drafts context-free. Expect lower acceptance from those. --prompt-lookup-ngram N --gamma Gbeside--mtp-modelcontinues each head draft by lookup (speculative::Extended): a needle of exactly N tokens ending in the drafted token adds up to G tokens. Greedy batches only.
- SYMPTOM: CUDA GGUF prefill was BOTH slow (eager O(n^2) collapse: pp128 2158 -> pp512 2044 -> pp2048
1118, where a working flash path is flat) AND, when routed to flash, produced GARBAGE logits (byte-identical
钊,,,from token 1 -> corrupted KV cache -> whole generation garbled). - ROOT CAUSE (one bug, two symptoms): the
can_use_flashpath inSdpa::run_attentiontransposed q/k/v(b,H,s,d)->(b,s,H,d)WITHOUT.contiguous().flash_attn_v2hands the tensor straight to the CUDA kernel with no internal contiguous, so for seq>1 (prefill) the non-contiguous strides are read wrong -> garbage. Decode (seq==1) is trivially contiguous so it worked -- which HID the bug and made it look prefill-specific. Because flash garbled, GGUF prefill had been left on the eagernaive_sdpafallback (the O(n^2) collapse). ONE.contiguous()fixes both. - HOW IT WAS FOUND (methodology, not guessing): (1) an ISOLATION TEST (
flash_correctnessin attention/ mod.rs) provedflash_attn == naive_sdpato maxabs 0.0156 on controlled bf16 GQA-causal inputs WITH.contiguous()-> the crate/kernel is CORRECT (killed the "Blackwell bf16 kernel bug" theory). (2) aDBG_ATTNtrace showed the real prefill call ismask=CausalFlash q_dims=[1,32,512,128]-> thecan_use_flashpath (NOT the Custom-mask block I'd been chasing). (3) that path lacked the.contiguous()the passing isolation test had. Fix = add it. Earlier guesses (Custom-mask route, GQA repeat_kv, window, bf16 dispatch) were all on the WRONG path -- the trace corrected course. - RESULT (8B CUDA, spark GB10, main + fix): prefill coherent + FLAT -- pp512 2653 (pure) / 2349 (with decode); pp2048 ~2586 flat vs the old eager 1118 (the big long-context win). Decode 35.8. The pp2048 collapse is gone. Fixes flash prefill for ALL CUDA models (any that hit the CausalFlash path), not just GGUF. What prefill time remains is kernel efficiency, not an algorithmic O(n^2) defect.
- Flash dispatch:
using_flash_attn()(CUDA) is now default ON with aFLASH_PREFILL=0opt-out; flash is used iff that predicate AND applicable (device/dtype/shape/causal). ROCm flash is always-on when applicable.
- SYMPTOM: Qwen3-8B-Q4K_M on CUDA (and Vulkan) collapses to
钊,,,repetition during LONG thinking-mode generation (plain prompt -><think>block). Short //no_thinkoutput is coherent. Metal is fully correct (same byte-identical GGUF -> "Paris"). 0.6B/4B fine on all backends. - LOCALIZED by A/B (seed 42, plain prompt): FORCE_EAGER (prefill+decode eager) = COHERENT; DECODE_EAGER (prefill flash, decode eager) = STILL COLLAPSES. So the collapse originates in flash PREFILL, not decode. NOT the file (identical sha256), NOT the quant kernel (DEQUANTIZE_ALL still collapses).
- ROOT CAUSE: the CUDA flash-attn prefill is ~1.5% numerically off vs eager naive_sdpa (isolation test
flash_matches_naivemaxabs=0.0156 -- larger than a correct flash's ~0.4% bf16-reorder). That error, accumulated across 36 layers, tips 8B's sensitive thinking-mode trajectory into the repetition bifurcation. Eager (f32 softmax) is exact and stays stable; Metal's attention is a different, stable impl. - THE TRADE-OFF THIS EXPOSES: the
.contiguous()flash-prefill fix (573d12614) gave prefill 0.37x->0.86x flat BUT introduced this collapse (pre-fix, GGUF prefill used eager = correct-but-slow). So main now ships fast-prefill + 8B-long-gen-collapse on CUDA/Vulkan. Options: (a) revert to eager (correct, slow prefill); (b) fix flash's softmax to f32-accumulate precision (deep, in hanzo-flash-attn -- the real fix, keeps perf); (c) the kernel-DSL migration (one correct attention impl across backends -- the structural cure). DECODE_EAGER is a NEGATIVE (does not fix) -- do not ship it. - NEXT: diagnose whether hanzo-flash-attn accumulates the softmax in f16 vs f32 (the likely 1.5% source) and force f32. If the flash kernel is correct, the 1.5% is the is_causal alignment -- verify the causal triangular direction matches naive. Until fixed, 8B-long-thinking on CUDA/Vulkan is a known-issue (usable for short/direct gen; Metal fully correct).
using_flash_attn()flipped default ON (FLASH_PREFILL=0opts out) after bumping hanzo-flash-attn to 0.11.35 (routes bf16 softmax-P through the f16 kernel's 10 mantissa bits vs bf16's 7).- DENSE bf16 8B on CUDA: flash prefill is BYTE-EXACT vs eager (greedy seed-0, first-prefill non-varlen path) and 1.34x@pp512 / 2.46x@pp2048 faster (flat vs length: eager 2110/1166, flash 2825/2868 T/s). Decode 14.8->15.0 (paged kernel, unchanged). d_flash == d_bf16raw == d_eager, so the P-cast is NOT what tips dense here.
- GGUF/quantized garble under flash and are GATED to eager via
CausalMaskConfig::gguf()(allquantized_*models). Verified: Qwen3-8B-Q4K flash emits corrupted text ("-devel...corrupted") that is P-precision-INDEPENDENT (q_flash == q_bf16raw), so it is NOT the 1.5% P-cast issue above -- it is structural: GGUFforward_attnhas no FlashParams plumbing (passesNone), so flash runs without cumulative seqlens. Q4K + MoE flash == eager (coherent "Paris") after the gate. This is the same config as the "8B-Q4K thinking-mode collapse" above. - The paged prefix-cache/chunked GATHER path (varlen, q_len<kv_len) is coherent under flash but its greedy output differs from a fresh (non-cached) run -- and EAGER gather differs from fresh too, so this is a pre-existing prefix-cache non-transparency (reduction-order/fp between gather vs non-gather kernels), NOT a flash-specific garble. Single-shot generation never hits the gather path (needs a prefix-cache hit), so the default-ON win is safe there. A transparent gather (build-2) remains a separate follow-up.
- Tested the sane-sampling fix (rep_penalty 1.1, top_p 0.9, temp 0.7) on 8B seed42 flash prefill:
STILL COLLAPSES --
针needle needle needle...(different token than钊,,,, but still a repetition attractor). So the collapse is NOT a sampling artifact -- rep-penalty does not rescue it. - CONCLUSION (all hypotheses eliminated by A/B): the collapse is hanzo-flash-attn's PREFILL being subtly off. Its softmax is correctly f32, yet its output (~1.5% vs eager) tips 8B-Q4K bf16 into a repetition attractor -- while EAGER and Metal's attention both keep 8B stable. So hanzo-flash-attn has a subtle numeric error, NOT just "expected reorder".
- The sampling change is kept as a genuine DEFAULT improvement (temp 0.1/top_p 0.1/no-penalty was bad), but it is NOT the collapse fix. Do not present it as such.
- REAL FIX PATHS: (a) revert flash prefill -> eager (correct, prefill 0.37x -- correctness-first); (b) fix hanzo-flash-attn's CUDA kernel by numeric-diffing its prefill output against the eager path's to find the subtle error (deep, keeps the 0.86x perf); (c) the kernel-DSL migration (one correct attention impl -- structural cure). Production (server/API, client-set sampling, short gens) is largely unaffected; the collapse is a bare-CLI long-thinking-gen issue on CUDA/Vulkan.
FINAL VERDICT (numeric diff done): 8B collapse = bf16 BIFURCATION SENSITIVITY, not a fixable flash bug
- The flash-vs-eager seq sweep (attention::flash_precision_probe, CUDA, bf16 GQA causal 8B-shape) settles it: seq=8 max_abs=0.0156 | seq=32 0.0156 | seq=128 0.0156 | seq=512 0.0176 The error is FLAT with seq at ~0.0156 = ONE bf16 ULP for these magnitudes. So it is NOT a systematic algorithmic bug (which a short-seq test would also show, but a real bug would be a LARGER fixed offset) and NOT reduction-reorder (which would SCALE with seq). It is the bf16 OUTPUT-QUANTIZATION floor: flash and eager compute slightly-different-but-both-VALID f32 attention outputs that round to adjacent bf16 values. flash's softmax is f32-correct (verified: MaxOp/SumOp/ElementAccum=float).
- CONCLUSION: there is NO clean flash bug to fix. 8B-Q4K at bf16 sits on a repetition bifurcation; flash's valid rounding tips it, eager's valid rounding does not (Metal is stable because it uses its OWN eager/ steel attention, NOT the CUDA flash crate). Better sampling did not rescue it (rep-penalty still looped).
- SHIPPED RESOLUTION (pragmatic, CTO call): KEEP flash prefill on main -- the 0.86x-flat prefill win is
real and broad; correct for the vast majority of inputs; production (server/API, client sampling) is
unaffected; the collapse is a NARROW bare-CLI long-thinking-gen edge on the one sensitive 8B-Q4K model.
The CLI sampling defaults were also improved (temp 0.7/top_p 0.9/rep-pen 1.1 -- objectively better than
the old temp 0.1/top_p 0.1/no-penalty) though they do not fix the bifurcation.
flash_vs_eager_seq_sweepstays as the regression gate. - THE PERMANENT CURE is the kernel-DSL migration (hanzo-kernel, proven CPU+Vulkan+Metal this session): ONE correct attention implementation lowered to every backend eliminates the flash-vs-eager-vs-Metal numeric fork by construction -- the whole "same op, N impls, N bf16 roundings, N bifurcation outcomes" class.
- Target = microsoft/TRELLIS-image-large (the mature 1.2B model). TRELLIS.2-4B (DINOv3, ConvNeXt VAE, RoPE, shape/texture cascade) is the successor and a 3x heavier follow-up, NOT this port.
- Pipeline stages: image -> DINOv2 tokens -> sparse-structure rectified-flow -> Conv3d decode to a 64^3 occupancy grid -> [SLAT sparse flow -> FlexiCubes mesh] -> GLB. The DENSE half (through occupancy) is fully ported; the SLAT/FlexiCubes half is the frontier (needs sparse-conv infra + FlexiCubes).
- ALL dense stages are BIT-EXACT vs the torch reference oracle (cos=1.00000000):
dinov2 (max|d|=8.2e-5), ss_flow (mse=3.0e-12), ss_decoder (mse=2.3e-9).
Oracle = TRELLIS's own pure-torch code run CPU-only with ATTN_BACKEND=sdpa and the sparse/render
subpackages stubbed (
sys.modules['trellis.{pipelines,renderers,representations}']=ModuleType(...)), so no spconv/nvdiffrast/flash needed to validate the dense models. Scripts in scratchpad/trellis_oracle. - Key facts pinned from the reference source (do not re-derive):
- DINOv2 = torch-hub
dinov2_vitl14_reg(NOT the HF-naming variant): fused qkv, LayerScale ls1/ls2, 4 registers inserted AFTER cls (no pos-emb), 518->1374 tokens, exact-erf GELU; TRELLIS usesx_prenorm(before the finalnorm) then a non-affineF.layer_norm(eps 1e-5). Block norms eps 1e-6. - ModulatedTransformerCrossBlock (SS + SLAT share it): norm1/norm3 non-affine, norm2 affine; self-attn is adaLN-gated with per-head qk-RMSNorm (= L2-normalize over head_dim * gamma[H,D] * sqrt(D)); cross-attn to the DINOv2 tokens is un-gated/un-modulated; FFN is tanh-GELU, adaLN-gated. adaLN = SiLU->Linear(C,6C).
- Sampler passes t*1000 to the model; timestep_embedding is cos-then-sin; SS pos_emb is a stored buffer.
- FlowEuler: t_seq=linspace(1,0,steps+1) reparam by rescale_t=3; step x-=(t-t_prev)*v; CFG only inside cfg_interval [0.5,1] as (1+s)cond - suncond, s=5; neg_cond = zeros_like(cond).
- DINOv2 = torch-hub
- hanzo-ml has NO Conv3d: the decoder's only kernel (3x3x3, pad1, stride1) is decomposed into 3 depth-sliced conv2d summed with depth alignment (ss_decoder.rs::Conv3d). hanzo-ml's tuple Shape/Dims cap at rank 6 -> use Vec/&[usize] for the 8-D pixel_shuffle_3d reshape/permute.
- Weights:
PIXAL3D_MODELdir holds the TRELLIS ckpts (ckpts/*.safetensors) + a converteddinov2_vitl14_reg.safetensors(torch-hub .pth has no safetensors; convert once). Runs Device::Cpu today. - GLB export = hand-rolled binary glTF 2.0 (glb.rs), validated against the third-party
gltfloader; wired into ThreeDFormat::Glb (was a PLY fallback). The /v1/3d async endpoint already calls pixal3d_generate. - SLAT + FlexiCubes half is now DONE (the fine textured mesh), all bit-exact vs the reference:
- sparse.rs = the sparse-tensor infra (SparseTensor coords[N,3]+feats[N,C], B=1): submanifold Conv3d (SubMConv3d, gather active neighbours c+(kz-1,ky-1,kx-1)), SparseLinear, downsample/upsample/ subdivide, SparseGroupNorm32, AbsolutePositionEmbedder, window partition. GOTCHA: spconv's CPU build is ~2% WRONG on sparse SubMConv (single-tap dense is exact, multi-tap sparse gather-gemm is buggy) -- the correct oracle is dense conv3d @ active sites (== GPU spconv). GOTCHA: SparseDownsample uses torch.scatter_reduce(mean, include_self=True) so the divisor is (group size + 1), not the size.
- slat_flow.rs (SLatFlowModel) reuses the SS-flow ModulatedCrossBlock verbatim (full attention over the voxel set == dense sdpa at B=1) + SparseResBlock3d pack/unpack (downsample 64->32, 24 blocks, upsample 32->64 with U-Net skips). NO stored pos_emb (on-the-fly AbsolutePositionEmbedder from coords). Parity cos=1.0000, max|d|=5.2e-5.
- slat_decoder.rs (SLatMeshDecoder): 12-block sparse SWIN transformer (windowed self-attn, alternating shift 4, non-affine norms), 2 SparseSubdivideBlock3d (GroupNorm32+subdivide+conv residual) 64->128->256, out_layer -> 101-ch FlexiCubes layout (sdf 8 + deform 24 + weights 21 + color 48). Parity cos=1.0000.
- flexicubes.rs (+ flexicubes_tables.rs) = nvdiffrec FlexiCubes dual-MC, inference path. Ported SPARSELY: the SDF grid is 1 everywhere except active-voxel corners, so only cubes touching an sdf<0 vertex can be surface -- no dense 256^3 arrays. sparse_cube2verts corner-mean, DMC case + check_table ambiguity inversion, beta-weighted dual verts from alpha-weighted edge zero-crossings, 6-ch color interp, quad triangulation split by gamma with sdf winding. Cubes processed in reg_c (z,y,x) order so the 4-cube quad winding matches. Parity vs golden mesh: verts 32981==32981, faces 63978==63978, Chamfer=0.000000.
- pipeline.rs generate_textured(): coarse occupancy -> active coords (argwhere>0, res 64) -> SLAT flow
denoise (from noise, CFG) -> denormalize (*std+mean, consts SLAT_MEAN/STD) -> mesh decoder -> FlexiCubes.
/v1/3d exposes it via the
texturerequest flag; GLB carries per-vertex RGB as COLOR_0 (mesh_to_glb_colored).
- Oracle method for the SLAT half: real TRELLIS + real CPU spconv, with a flash_attn shim (sdpa CPU) so the real code runs, trellis.{pipelines,renderers,gaussian,octree,radiance_field} stubbed, kaolin.check_tensor stubbed, FlexiCubes forced device=cpu, and SparseConv3d monkeypatched to the correct submanifold gather (CPU spconv is buggy). Scripts in scratchpad/oracle (gen_oracle.py + flash_attn.py).
- Model: GSAI-ML/LLaDA-8B-Instruct (MIT, ungated, most-downloaded dLLM). Same code loads LLaDA-1.5 + LLaDA-8B-Base (identical arch). Rejected: DiffuCoder (apple-amlr research-only); Dream-7B (Apache, qwen2- arch) is the fallback; LLaDA-MoE-7B-A1B (Apache) is future.
- Arch = Llama with 2 differences: BIDIRECTIONAL attention (no causal mask) + iterative masked-diffusion generation (no KV cache, whole sequence re-forwarded each step). OLMo config names (d_model 4096, n_heads 32, n_kv_heads 32 = MHA, n_layers 32, mlp_hidden_size 12288 SPLIT SwiGLU ff_proj gate + up_proj, ff_out down, rms_norm, rope_theta 500000 NeoX, untied lm_head = transformer.ff_out, vocab 126464, mask 126336). Reuses RmsNorm/Sdpa/RotaryEmbedding/ReplicatedLayer verbatim; the ONLY structural change vs llama.rs is the mask. Bidirectional = AttentionMask::None + FlashParams::empty(false) (pitfall 6). Weight prefix is model.transformer.{wte,blocks.N.*,ln_f,ff_out}.
- Sampler (official LLaDA generate.py, ported): semi-autoregressive blocks (block_length), low-confidence remasking (confidence = max softmax prob = softmax at argmax), linear noise schedule (num_transfer_tokens), greedy (temp 0). Per step: full forward -> argmax x0 + confidence -> unmask the top-k highest-confidence masked positions within the current block. Selection on CPU (tiny), forward on GPU (dominant). Deterministic.
- PARITY (GB10, bf16, vs transformers-4.46 oracle -- tf5.x breaks LLaDA remote code via all_tied_weights_keys): gate1 single-forward logit cosine > 0.99; gate2 generation BYTE-IDENTICAL on deterministic prompts (France ->"Paris" [65926,eot,eos...] 32/32; 17*23->"391" [18,24,16,...] 32/32); haiku 19/32 = legitimate bf16 confidence-tie nondeterminism (both valid). Coherence: "Paris", coherent haiku.
- THE DISTRIBUTED THESIS, measured (gen=128, block=32, GB10 bf16): tok/s scales LINEARLY with tokens-per- traversal (steps 128->1.0 tok/traversal->6.9 T/s; 64->2.0->13.6; 32->4.0->27.7). Each denoise step = ONE model.forward = ONE ring traversal (pp_head_forward). So a 2-node ring PP dLLM amortizes the traversal latency over MANY tokens per hop -- inverting AR's 1-token-per-traversal economics. PP wiring is a thin adapter: PipelineParallelModel = {pp_embed=wte, pp_run_local=local block range, pp_norm_head=ln_f+ff_out}; loop pp_head_forward N times = N traversals. (Trait in pipeline_parallel.rs; single-node sweep proves the economics; 2-node execution + /v1 serving pipeline are the remaining wiring.)
- No-KV-cost caveat: every step re-forwards the whole [prompt+gen] sequence, so per-step cost grows with sequence length (Fast-dLLM-style block KV cache is the known follow-up). gated tests: llada_parity, llada_smoke (LLADA_WEIGHTS + LLADA_ORACLE / LLADA_GEN/STEPS/BLOCK).
- Made the composable AceStepPipeline (diffusion_models/ace_step/) servable as a new SpeechLoaderType. UMT5-base text encoder + DiT flow-match (APG guidance) + DCAE + HiFi-GAN vocoder -> stereo 44.1kHz.
- Registration mirrors TTS exactly: SpeechLoaderType::AceStep (FromStr
ace_step|ace-step|acestep, auto-detect on the DiT config's_class_name == "ACEStepTransformer2DModel"), SpeechGenerationConfig:: AceStep { frames, steps, guidance_scale } (default ~10s = 10*44100/4096 frames, 27 steps, guidance 15). Reuses ModelCategory::Speech + Response::Speech + RequestMessage::SpeechGeneration -> ForwardInputsResult ::Speech; forward_inputs tokenizes with the UMT5 tokenizer (umt5-base/tokenizer.json), runs generate(), and interleaves the (1,C,S) waveform into PCM (channels=2, rate=44100). - Loader (pipeline/speech.rs) fetches the 4 real ACE-Step sub-checkpoints from ACE-Step/ACE-Step-v1-3.5B:
umt5-base/model.safetensors (keep all), ace_step_transformer/diffusion_pytorch_model.safetensors (drop
the unused
lyric*/projectors/.add_/.to_add_outheads), music_dcae_f8c8/... (keepdecoder.*), music_vocoder/... (keep all). All loaded f32 (vocoder/DCAE fidelity). Key filters byte-match the proven ace_step_generate_e2e test fixtures; the DCAE f8c8 gives 4096 samples/latent-frame (44100/4096 ~10.77 fps). - Server: POST /v1/audio/music (MusicGenerationRequest {model,input,response_format}) in hanzo-server-core/src/music_generation.rs reuses speech_generation's parse + response plane (wav/pcm). Merged to main (5c8b8968b). Per-request duration/steps/guidance = config-driven (load-time), same as TTS; threading them per-request would need RequestMessage::SpeechGeneration to carry gen params (follow-up).
- Engine (this repo) serves the OpenAI-adjacent /v1 modality plane: /v1/chat/completions (text+dLLM),
/v1/images/generations, /v1/audio/speech (TTS), /v1/audio/music (ACE-Step), /v1/audio/transcriptions
(ASR), /v1/3d (TRELLIS/Pixal3D,
textureflag), /v1/videos (WAN async), /v1/animate (MuseTalk dub). Deployed via platform.hanzo.ai on DOKS behind the gateway (api.hanzo.ai). - studio.hanzo.ai (hanzoai/studio, ComfyUI fork; deployed by hanzo.yml onto do-sfo3-hanzo-k8s / studio
Service CR) is the PRIMARY authoring surface: the
hanzo_enginenode pack (custom_nodes/hanzo_engine) maps one node == one /v1 endpoint over a shared client; the flagship workflow hanzo-full-generative- pipeline.json chains Chat -> ImageGen -> (textured ImageTo3D + WAN video) + TTS + Music. - hanzo.app (app builder + /games) is the consumer/preview surface for the produced assets.
- PRODUCTIZATION GAP (hosted multi-tenant), named precisely from the studio code:
(1) Engine URL is not per-tenant: the node pack reads process-global HANZO_ENGINE_URL (default
localhost:1234); it is set NOWHERE in the studio repo, and the per-org engine/worker routing
(middleware/engine_selector.py + compute_config.py + prompt_router.py, which route the graph to
local vs a BYO-GPU worker) never derives or sets the node pack's /v1 base URL per org/worker.
(2) Identity is not propagated to the engine: iam_auth_middleware.py authenticates the Studio surface
(Hanzo IAM JWT, org from the
ownerclaim) and worker_client signs Studio->worker calls, but custom_nodes/hanzo_engine/client.py sends the /v1 calls with NO Authorization header -> a gateway- fronted engine (api.hanzo.ai injects identity, scopes byowner) gets an anonymous/unscoped call. (3) Hosted GPU + model availability: /v1/3d,/videos,/audio/music,/animate are GPU-bound and need those specific models loaded; the compute layer schedules the ComfyUI graph on GPU workers but there is no per-org binding of "engine deployment X (with ACE-Step/WAN/TRELLIS/MuseTalk loaded) serves this org". Self-hosted single-engine works TODAY (set HANZO_ENGINE_URL, runhanzo-engine serve); the gap is the 3-part wiring to make it hosted SaaS: per-org engine-URL resolution from engine_selector, IAM-token forwarding onto the node /v1 calls, and per-tenant engine/model provisioning.
- WHY: unblock loading GLM-5.2 in-engine. The loader is
models/quantized_deepseek2.rs, which acceptsgeneral.architecture in {deepseek2, glm-dsa}; GLM-5.2 = glm-dsa (split-MLA + 256-expert MoE). - DISK-SAFE: stream one ~5 GB FP8 shard, requantize, delete it,
next. But GGUF's whole tensor directory precedes its data blob, so it can't append per shard. So: (1)
PLAN the full directory + metadata from config.json, write header/KV/tensor-info, preallocate; (2) stream
shards,
pwriteeach tensor (or one expert's slice of a rank-3 bank) into its fixed offset. Resumable via a.progress.jsonsidecar keyed by a plan signature (offsets shift if any --*-type/config changes). - THE glm-dsa GGUF CONTRACT (matched byte-for-byte to the reader + utils/gguf_metadata.rs):
- routed experts -> rank-3
blk.{i}.ffn_{gate,up,down}_exps.weight, ne=[hidden, moe_inter, n_expert], Q4_K. - router
ffn_gate_inp.weight+ no-auxexp_probs_b.bias-> F32 (reader dequantizes them). - split-MLA:
attention.key_length=qk_nope,key_length_mla=q_head_dim,value_length_mla=v_head_dim,rope.dimension_count=qk_rope. q_lora path uses attn_q_a/attn_q_a_norm/attn_q_b; kv_b shipped combined. expert_gating_func=2 (sigmoid),expert_group_count=1 (noaux),leading_dense_block_count=first_k_dense.- MTP/nextn block at index n_layers, emitted Q8_0 -- an int4 draft head measures ~0% acceptance, so
speculation never starts.
nextn_predict_layersrecords it; the text loader drops it from block_count (n_layers = block_count - nextn). - VALUE-TYPE gotchas from hanzo-ml gguf
Value:to_f32accepts ONLY F32 (rms_eps/rope.freq_base/ expert_weights_scale must be FLOAT32, not F64);to_boolONLY Bool;to_u32/to_u64upcast U8/U16/U32.context_lengthis.unwrap()ed by the device mapper -- must be present.
- routed experts -> rank-3
- QUANT: gguf-py 0.19 quantizes Q8_0/F32 but NOT the K-quants (it does dequantize Q4_K). So Q4_K is a
faithful
quantize_row_q4_K_ref(make_qkx2 + K_SCALE packing, numpy-vectorized over super-blocks), proven by round-tripping through gguf-py's own Q4_K dequantizer (--selftest, relerr ~0.075). - VALIDATED here without the 756 GB checkpoint:
--selftestbuilds a tiny synthetic glm-dsa GGUF end-to-end and asserts every reader-required key/type/tensor/shape via gguf-pyGGUFReader; an on-disk FP8(e4m3 128-block)+BF16 safetensors fixture exercised the--indirstreaming path and resume. NEEDS a real zai-org/GLM-5.2-FP8 checkpoint for full-scale conversion + an in-engine load smoke test (and the tokenizer is loaded separately -- GGUF vocab embedding is not done here).
hanzo-router/src/scheduler.rs extends the existing replica pool for the HTTP
front: scoped session pins, successful-response prefix locality hints, configured
worker roles/capacity/throughput weights, atomic admission, and observed TTFT
EWMA. The legacy ReplicaSet::pick ring API remains available; the HTTP front
uses pick_agent. New sessions require a slot; pins tolerate 2× capacity before
reassignment. A session stays on the failover worker after recovery. Pins expire
at 30 minutes, prefix hints at five, each bounded to 10,000 rows per model.
X-Session-Id / X-Agent-Role / X-Target-Replica or JSON metadata
session_id / agent_role / target_replica carry hints; keys are SHA-256 of the
principal/model namespace and structured session/prefix input, never stored raw
prompts. The listener defaults to loopback and trusts identity from the private
cloud gateway, not public clients. Its replica admin surface is private too.
Cloud's existing metered relay preserves JSON metadata but not arbitrary headers;
cloud owns the composition in native/engine-router and deploy/engine-pool.yaml.
No KV migration or engine-reported cache/queue telemetry is claimed: completed
2xx response prefixes are locality hints. Streams release leases on EOF/error/drop;
only pre-connect errors retry, never timeouts or partially delivered streams.
- Spark: Primary engine (NVIDIA Blackwell GB10 NVFP4 on
:30000, 128GB VRAM). Designated for Main Thread (TTFT 133ms, 1M context capacity). - Evo: Secondary engine (AMD Strix Halo Ryzen AI Max+ 395 Q6_K on
:8080, 128GB unified memory). Designated for Subagents (TTFT 618ms). - Both nodes run
hanzo-routeron port:1235with bidirectional awareness:- If Spark fails: router automatically evicts Spark and routes Main Thread to Evo.
- If Evo fails: router automatically spills Subagents to Spark.
- In Kubernetes,
hanzo-routerendpoints include both nodes on:1235behindhanzo-ingress(Traefik). Addresses live in the private record, not here.
- Problem: During long reasoning or deep thinking cycles (e.g. 10–18 minutes), upstream generation pauses between tokens or during prefill caused NAT firewalls and client watchdogs (
CLAUDE_STREAM_IDLE_TIMEOUT_MS, default 300s) to abort connections with "API Error: Connection lost mid-response". - Solution in
hanzo-router/src/proxy.rs:LeasedStreammaintains a 15-second timer. If no data arrives from upstream for 15s during an active SSE stream, it emits an SSE comment (: keep-alive\n\n).- Standard SSE clients silently ignore comment lines starting with
:, while the socket receives live TCP packets that reset idle watchdog timers and keep stateful NAT tables alive. response_with_progresscovers the other half: an engine that sends no HTTP headers until prefill completes gets the same pings, and an upstream error arriving after the first ping is delivered as an SSEevent: error. Verified through Cloudflare: a 188K-token request held the connection for 244s of pings, then streamed.- Upstream
reqwest::Client: 30s connect timeout, 15s TCP keepalive, TCP nodelay, 4s pool idle timeout. The replicas' HTTP servers (uvicorn, cpp-httplib) drop idle keep-alive connections at 5s, which is also the probe period, so a longer pool timeout hands the client a socket the engine is closing; those failures evicted healthy engines every few cycles. - Client workstations (
raanddbc) configureAPI_TIMEOUT_MS=3600000andCLAUDE_STREAM_IDLE_TIMEOUT_MS=3600000.
- Admission by tokenizer count. Each replica declares
max_contextinrouter-pool.yaml; a request is admitted only where prompt + requested output fits. Counts come from each replica's own/v1/messages/count_tokens(pool members serve different models and templates, so counts are not interchangeable), falling back to a byte estimate when a tokenizer cannot answer. Session pins, cached prefixes, roles and explicit targets all yield to capacity. When nothing fits, the client getsinvalid_request_error"prompt is too long", which Claude Code answers by compacting. - Liveness is
/v1/models, not/health. spark's/healthruns a one-token generation through the scheduler, so behind a long prefill it answers in seconds; a 2s probe evicted spark, the only replica that could hold a long conversation, and compaction got "no available replica". Probes now ask/v1/modelswith a 10s ceiling, once per upstream per cycle (not once per model pool), concurrently, and ask twice before declaring a server down. - Vision. A replica declares
vision: falsewhen its engine has no image encoder (a GGUF replica without a vision projector answers 500). Image-bearing requests — Anthropic image blocks including inside tool results, OpenAIimage_url, Responsesinput_image— skip those replicas and are not token-counted there. tool_referenceblocks. ToolSearch results carry them; local engines 500 on them and poison every later turn, so the router rewrites them to text.[1m]model suffix. Claude Code sizes its context window from the model name: with abehavesAspicker row it uses the mapped model's window and ignoresCLAUDE_CODE_MAX_CONTEXT_TOKENS, so only a[1m]suffix yields 1M. The client strips the suffix before the request and sends thecontext-1mbeta header instead; the router strips it too so the pool still resolves.- Drain needs systemd's permission. The router waits up to 30 minutes for in-flight work on SIGTERM, so both boxes carry
~/.config/systemd/user/hanzo-router.service.d/drain.confwithTimeoutStopSec=1800. Without it systemd SIGKILLs at 90s and the drain is decorative. - Restarts are not free. Restarting a router kills in-flight requests, including ones still in the token-counting phase that hold no lease, so an "inflight == 0" drain check is not sufficient. Restarting an engine empties its prefix cache: appended turns normally reuse 166-177K cached tokens and prefill only 17-1,425 new ones, but after a restart or an aborted request the retry reused just 16,384 of a 154K prefix (a hybrid model's recurrent state is kept only at tracked points).
hanzo-quant/kernels/nvfp4/nvfp4_gemm.cuexpands E2M1 in registers off the same LUT and__byte_permtrick MXFP4 uses. Only the scale differs -- FP8 E4M3 per 16 against E8M0 per 32 -- so oneuint4load carries two blocks and two scales, ande4m3_to_floatfolds the checkpoint's FP32weight_scale_2into each block scale.M <= 4takes the vecmat decode path, wider shapes a register-tiled one; K must be a multiple of 32.NVFP4Layerkeepsweight/weight_scalepacked on CUDA (Weight::Packed) and still dequantizes at load everywhere else.- Gate:
nvfp4_matmul_matches_dequantizedon a GB10 compares against dequantize-then-matmul at N/K of 64/128, 512/5120 and 256/17408 for M in {1, 2, 4, 17, 64, 512}. - Why it is a correctness fix and not a tuning one: dequantize-at-load turned a 15 GB checkpoint
into a 104 GB device-map plan on a 121 GB box and died at
CUDA_ERROR_OUT_OF_MEMORY. Packed, the same model loads in ~31 GB. - A ModelOpt checkpoint is mixed, per module.
RadixArk/Qwen3.8-27B-NVFP4-BF16-LMHeadships 193 NVFP4 modules (the MLPs:weightU8 [N, K/2],weight_scaleF8E4M3 [N, K/16],weight_scale_2F32) and 208 FP8 ones (linear_attn.in_proj_qkv,in_proj_z,out_proj:weightF8E4M3 [N, K], per-tensor F32weight_scale).load_modelopt_linearroutes both, so anything that reads a weight with a rawvb.getinstead of the loader gets fp8 bytes and a matmul dtype mismatch -- which is exactly whatGatedDeltaNet::loaddid until it started going throughlinear_no_bias. - Still open: the GDN input projections are dequantized to bf16 so the grouped qkvz layout can be
built by interleaving rows (~4 GB on this model). Projecting
in_proj_qkvandin_proj_zseparately and assembling the outputs would keep them quantized.
Same box, same checkpoint, same harness (a streaming /v1/chat/completions client counting
content and reasoning_content deltas; every prompt carries a fresh nonce so a prefix cache
cannot report a prefill rate the engine never achieved).
spark (GB10, RadixArk/Qwen3.8-27B-NVFP4-BF16-LMHead), hanzo-engine with the native NVFP4 kernel:
| prompt | prefill, scalar tile | prefill, WMMA | decode |
|---|---|---|---|
| 181 | 215.5 | 296.6 | 6.99 |
| 1,293 | 236.9 | 357.9 | 6.91 |
| 12,453 | 214.5 | 309.9 | 6.08 |
Tensor cores buy 38-51% of prefill and nothing on decode, which is the expected shape: a 16-wide MMA wastes 15 of its slots at batch 1. A wider K tile (two uint4 per row, halving the A-tile reloads and the barriers) measured 15-22% SLOWER -- 16 KB more shared memory costs more occupancy than the reuse buys -- and is recorded as a dead end in the kernel. Decode reads ~31 GB of weights per token against the checkpoint's computed 19.44 GB (below) and moves them at ~217 GB/s, which is 79% of the 273 GB/s the box has -- so decode is not slow, it is reading too much, and the ~4 GB is the GDN input projections this tree dequantizes. Prefill is the real gap and it is arithmetic: the MLP GEMMs run at roughly a tenth of the tensor core's rate.
spark after porting upstream's block-scaled FP4 GEMM (perf/nvfp4-prefill, d4c711170a). The
CUTLASS kernel builds for sm_121a on CUDA 13.0; upstream's 13.3 pin and cuTile activation quantizer are
not needed by the kernel, so the port quantizes activations with its own CUDA kernel against the
checkpoint's input_scale. Decode (M <= 4) stays on the vecmat kernel.
| engine | prompt tokens | prefill T/s | decode T/s |
|---|---|---|---|
| hanzo-engine, FP4 GEMM | 179 / 1,293 / 12,453 | 648 / 950 / 646 | 7.00 / 6.86 / 6.56 |
| hanzo-engine, WMMA | 175 / 1,293 / 12,453 | 297 / 358 / 310 | 6.99 / 6.91 / 6.08 |
Where the remaining prefill time goes (nsys, pp1291, GPU kernel time): bf16 GEMM over the FP8
attention layers that pertensor_fp8 dequantizes at load 27.3%, copies 13.2%, FP4 GEMM plus
activation quantize 13.0%, elementwise 11.8%, casts 10.6%, GDN recurrence 9.2%. The same dequantized
FP8 layers set decode: 26.65 GB read per token against the computed 19.44, at 187 GB/s. Native FP8
linear is the next lever for both.
The harness is checked in (scripts/bench_http.py, tests in scripts/bench_http_test.py). Prompt
= uuid nonce + WORDS x size + "Summarize in one sentence."; sizes 4/40/400/4000 are ~220 / 1,335 /
12,497 / 124,094 Qwen3.8 tokens, and the prompt length reported is the server's usage.prompt_tokens.
A token chunk is one whose delta carries content, reasoning, reasoning_content or tool_calls. TTFT and the decode window use token chunks only; decode =
(usage.completion_tokens - 1) / (last token chunk - first token chunk), so MTP/DFlash steps that
carry several tokens per chunk are counted as tokens, and a stream without usage fails (usage may arrive
on the finish chunk or in a later empty-choices chunk). --concurrency N reports each
stream plus the aggregate; --json FILE appends JSON lines.
python3 scripts/bench_http.py http://127.0.0.1:30000/v1/chat/completions --sizes 4,40,400,4000 --gen 128
python3 scripts/bench_http.py http://127.0.0.1:30000/v1/chat/completions --sizes 40 --concurrency 8
GB/token is two labeled numbers (scripts/bytes_per_token.py):
- computed weight+KV+state bytes per token from the safetensors headers and config.json, skipping
visual.*andmtp.*and counting oneembed_tokensrow;--resident REGEX=DTYPEnames modules an engine holds at another dtype.weights --checkpoint <27B snapshot> --context 1400 --kv-dtype bf16prints 19.44 GB of weights (19,435,407,488 B), 0.09 GB of bf16 KV (64 KiB/token over 16 full-attention layers) and 0.31 GB of GDN state: 19.84 GB per token. - measured SM-to-L2 bytes per token:
sudo nsys profile --gpu-metrics-devices=0 --gpu-metrics-set=gb20y-top, thennsys export --type sqliteandbytes_per_token.py nsys --sqlite F --tokens N(sums 'VidL2 Total from L1TEX [Bytes]'). GB10's metric sets (gb20y, gb20y-top) have no DRAM counter (RmProfilingAdminOnly=1, so metrics need root), so this is L2 traffic including hits: a cross-check that exposes redundant reads, never a substitute for the computed figure.
evo (Strix Halo gfx1151, Qwen3.8-27B Q6_K), hanzo-engine ROCm:
| hanzo-engine | |
|---|---|
| prefill 512 | 51.2 T/s |
| prefill 4,096 | 44.3 T/s |
| decode 128 | 7.3 T/s |
hanzo-engine bench --pa-context-len 16384; the serving harness agrees (35.8 T/s prefill / 5.15
decode). Prefill is the one to chase: gfx1151 has no tiled prefill path for Q6_K in our Vulkan backend
and ROCm is running the eager attention.
Two things had to be fixed before any of this could be measured at all, and both are worth knowing:
- The pager's demand floor ignored an explicit budget. On unified memory the KV cache is floored
at one full model context, so
--pa-memory-mb/--pa-context-lenwere raised back to 65 GB on a 124 GB box; a 27B bench then thrashed evo badly enough that SSH stopped answering and it took a privileged k3s pod on the node to kill the process. The floor now applies only to the automatic path. - A FlashInfer refusal used to kill the process.
flashinfer_decodeletflashinfer::Errorcross back into Rust: "fatal runtime error: Rust cannot catch foreign exceptions". Qwen3.8-27B-NVFP4 aborted on its first token unlessFLASHINFER_DECODE=0. Both decode entry points now return a status like the prefill one, and a refusal falls through to the standard paged kernel. - ROCm has no
scatter/scatter_addin hanzo-ml, and interleaved MRoPE usedTensor::scatter, so every Qwen3.5 GGUF step failed with "scatter_set not yet implemented for ROCm". It is a gather now. - FlashInfer was chosen for a GQA group it cannot decode. The gate tested
group <= 8, which is the prefill packing factor; the decode kernel instantiates 1, 2, 3, 4 and 8. Qwen3.5-27B has 6. Falling back at the call site cannot rescue it either -- the standard kernel reads block tables that are only built when it is the chosen backend -- so the gate is where it has to be right. - Long context on spark is blocked by the prefill attention, not by the quantization. With FlashInfer refused for this GQA group, prefill runs the eager path, which materializes the score matrix: a 4096-token chunk against a 124K prefix is 24 heads x 4096 x 124K x 4 B ~ 48 GB. The box wired ~110 of its 121 GB and stopped answering SSH. So 12.5K works and 124K does not, and the fix is a prefill that never builds the matrix (flash or FlashInfer), which is the "one prefill path" item in the plan.
- A 124K prompt died in
cumsum. hanzo-ml spells it as a triangular matmul, so the text-only MRoPE branch asked for a seq x seq matrix -- 15 billion elements -- and the launch came backCUDA_ERROR_INVALID_VALUE. The scan is linear and the mask is one byte per token, so it runs on the host now (positions_from_mask). - The GDN chunked prefill returned f32. Its fast path shadows
qwith its own f32 cast and then readsq.dtype()for the result, so any prompt of 64 tokens or more (CHUNK_THRESHOLD) came back f32 and met a bf16 weight in the next projection: "unexpected dtype, expected: F32, got: BF16" out of cublasLt. Under 64 tokens the same model answered fine, so it read as a streaming or sampling bug until the prompt length was swept.
- Redundant
cloudflareddaemons run natively on host nodes and as Kubernetes pods (hanzo/cloudflared-*). - Ingress
hanzo-domains-api-hanzo-aiterminates public traffic and routes tocloud:8000(which routes throughhanzo-router:1235to Spark and Evo). - Exposes full OpenAI
/v1/chat/completionsand Anthropic/v1/messages(with structuredthinkingblocks). - Metered via Hanzo IAM: authenticated calls via
Bearer <token>debit user/org quota and emit asynchronous usage records to the platform accounting database.
- Standard
clauderemains completely clean and untouched: no globalANTHROPIC_BASE_URLorANTHROPIC_API_KEYexports in shell profiles. - Dedicated launchers installed in
~/.local/bin/:claude-hanzopicks the model from the name it is invoked by and execsclaude; 1-hour stream timeouts;CLAUDE_CODE_AUTO_COMPACT_WINDOW=1000000. The window itself comes from the[1m]model suffix (see 2b), not fromCLAUDE_CODE_MAX_CONTEXT_TOKENS, which is ignored for a model carrying abehavesAsrow.- Effort is left to
~/.claude-hanzo/settings.json("effortLevel": "medium"); exportingCLAUDE_CODE_EFFORT_LEVELwould pin it and make in-session/efforta no-op. - Defaults to
https://api.hanzo.aiwith the IAM token fromhanzo auth token.--localuses the LAN router, which on macOS needs Local Network permission for ClaudeCode.app -- without it every LAN request fails asFailedToOpenSocketwhile curl from the same shell succeeds. - Dedicated configuration isolated to
~/.claude-hanzo/settings.json, preserving~/.claude/settings.jsonfor Anthropic/claude.ai.
- Symlinks:
claude-zen,claude-spark,claude-evo,claude-flash->claude-hanzo.~/.zshrc.d/hanzo-mesh.zshholds no aliases or functions of those names: an alias adding--model zen5.8-coderdrops the[1m]suffix and silently caps the window at 200K, and a same-named shell function shadows the executable and never launchesclaude.
- Switch active environment via
hanzo network use <network>:hanzo network use mainnet: Production L1 (chain 36963, RPChttps://rpc.hanzo.network, APIhttps://api.hanzo.ai).hanzo network use testnet: Testnet (chain 36962, RPChttps://rpc.testnet.hanzo.network).hanzo network use devnet: Devnet (chain 36964, RPChttps://rpc.devnet.hanzo.network).hanzo network use local: Local development network (chain 1337).
- Manage agent sandboxes and workspaces with
hanzo sbx(hanzo sbx list,hanzo sbx run <agent>,hanzo sbx models).
- Problem: When Claude Code issued web searches or tool calls, the request sent server-side tool definitions (e.g.
type: "web_search_20250101",tool_choice: {"type": "tool", "name": "web_search"}). The replica's Anthropic adapter dropped these tools as unsupported, causingValueError: tool_choice='tool' requires at least one custom tool; all supplied tools were server-side Anthropic built-ins which the OpenAI-compatible backend cannot invoke(HTTP 400). - Fix: spark's Anthropic adapter was patched to automatically synthesize function schemas for all Anthropic server-side built-in families:
web_search_*-> functionweb_searchwith{query: string}parameter schema.bash_*-> functionbashwith{command: string, restart: boolean}schema.text_editor_*-> functionstr_replace_editor/text_editorwith full command parameters (view,create,str_replace,insert,undo_edit).computer_*-> functioncomputerwith GUI action schemas (key,type,mouse_move,click, etc.).
- Result: The underlying Qwen 3.8 / Zen model generates function calls normally; the adapter translates them back to Anthropic
tool_useevents (name: "web_search"), which Claude Code receives, executes, and continues with zero 400 errors.
- Consistent Model Naming & 1M Context UI:
zen5.8-coder:Zen 5.8 Coder (1M Context · DGX Spark)(claude-sonnet-4-6[1m], medium effort)zen5.8-evo:Zen 5.8 Evo (1M Context · AMD Strix Halo)(claude-sonnet-4-6[1m], medium effort)zen5-flash:Zen 5 Flash (Fast · Strix Halo Evo)(claude-haiku-4-5, subagents & auxiliary tasks)zen5.8:Zen 5.8 (1M Context · DGX Spark)(claude-opus-5[1m])
- Launcher Binary Auto-Selection:
/Users/z/.local/bin/claude-hanzoinspectsbasename $0:claude-zen: automatically adds--model zen5.8-coder[1m].claude-evo: automatically adds--model zen5.8-evo[1m].claude-flash: automatically adds--model zen5-flash(no suffix; it is the short-context subagent model).claude-spark: automatically adds--model zen5.8-spark[1m].- The picker rows in
~/.claude-hanzo/settings.jsoncarry the same[1m]suffixes, so switching model in-session keeps the 1M window.
- Automatically injects
--dangerously-skip-permissionsby default (overridable with--safeor--ask). - Configures
CLAUDE_CONFIG_DIR=~/.claude-hanzoso standardclaude(Claude Max / OAuth) remains 100% clean and untouched.
- Sub-Agent Steering:
- Sets
CLAUDE_CODE_SUBAGENT_MODEL="zen5-flash",ANTHROPIC_DEFAULT_HAIKU_MODEL="zen5-flash", andANTHROPIC_SMALL_MODEL="zen5-flash". - In
router-pool.yaml,zen5-flashhas weight 1000 on Strix Halo Evo APU, offloading fast subagents and summaries from Spark's 1M context main engine.
- Sets
- Automatic MCP Registration:
- Registered
hanzo-mcpacross bothraanddbcworkstations (/Users/z/.local/bin/hanzo-mcpand/Users/a/.local/bin/hanzo-mcp) in~/.claude-hanzo/.claude.json. - Both machines verified healthy via
CLAUDE_CONFIG_DIR=~/.claude-hanzo claude mcp listreportinghanzo: ... - ✔ Connected.
- Registered
- Local MCP Capabilities (31 Tools):
- Exposes 31 native tools:
browser,cdp,playwright(headless browser execution),fetch,curl,wget,git,code,fs,lsp,todo,vision,exec,ps,npx,uvx,open,jq,version,tool, and the unified dispatcherhanzo. - These tools run on the local host with zero remote authentication dependencies.
- Exposes 31 native tools:
- Unified Cloud MCP Projection (
hanzo):- Rather than exposing all 120 cloud operations individually into the model's tool schema—which bloats the context and degrades tool routing accuracy—
hanzo-mcpexposes a unifiedhanzotool. - At runtime,
hanzodynamically introspectshttps://api.hanzo.ai/v1/openapi.jsonto route to all 120 services and operations:- Web Search:
hanzo(service="websearch", action="search", params='{"query": "..."}') - Web Crawling:
hanzo(service="crawl", action="...", params='...') - Cloud Infrastructure:
hanzo(service="paas", ...),hanzo(service="iam", ...),hanzo(service="billing", ...), etc. - Catalog exploration:
hanzo(service="services").
- Web Search:
- Rather than exposing all 120 cloud operations individually into the model's tool schema—which bloats the context and degrades tool routing accuracy—
- Direct Cloud MCP Endpoint (
api.hanzo.ai/v1/mcp) Protocol Compatibility:https://api.hanzo.ai/v1/mcp(incloud/client/mcp.go) hardcodesconst protocolVersion = "2026-07-28"in itsinitializeresponse.- Claude Code v2.1 client initiates MCP handshakes requesting
2025-11-25and validates against its supported version array:["2025-11-25", "2025-06-18", "2025-03-26", "2024-11-05", "2024-10-07"]. - Because
api.hanzo.aiunconditionally returns2026-07-28without down-negotiating to the client's requested protocol version, Claude Code throws:Server's protocol version is not supported: 2026-07-28. hanzo-mcpoperates as the local MCP bridge negotiating within Claude Code's supported protocol versions (2024-11-05/2025-11-25) while dispatching requests toapi.hanzo.ai.
- Authentication:
- Authenticated cloud services (
websearch,crawl,search) enforce tenant isolation and validated IAM principals (401: web search requires a validated principal). - Running
hanzo auth loginestablishes/refreshes the OIDC session in~/.hanzo/credentials.json, whichhanzo-mcpautomatically adopts.
- Authenticated cloud services (
- One router.
src/cuda/exact/route.cuis a verbatim port of a reference top-k gating kernel (its header records the source and licence), built in the exact lane (src/cuda/exact/*.cu, no--use_fast_math). It serves every MoE model throughops::moe_router_topkfor 15 fused expert counts (1-512 powers of two, 192, 320, 384, 448, 576); other counts keep the portable path. Bitwise vs the recorded reference outputs on 495 cases (15 counts x f32/bf16/f16 x 11 configs); the same source with fast math differs in 173,639 words. - The gate is cuBLAS's choice, and the workspace changes it.
cuda::lane::Lane::linearis torch'sF.linear(GemmEx T/N, COMPUTE_32F, DEFAULT_TENSOR_OP) on a handle carrying torch's 32 MiB workspace. Without it (hanzo-ml's handle, or 4 MiB) the N=512 gate differs at M 98-101, 126-160, 184-256, 289-320. Never route the gate through hanzo-ml matmul or UnquantLinear. - cuBLAS class map on GB10, cuBLAS 13.1.1 (rows bitwise equal across M within a class): N=512: {1, 17-183, 257-320} | {2-16} | {184-256, 321-4096}; N=1: one class. {2-16} is the reduced-precision split-K kernel (57-59% of logits correctly rounded vs 100% at M=1). The capture lists [1,2,4,8,16] and [1,2,4,5,8,10,16,20,...,80] never pad across a class; a full 512 grid would, at T=177..183 -> 184 (C3/W3 owns batch padding).
- Pin.
lane::CUBLAS = 13.1.1(the reference was recorded on nvidia-cublas 13.1.1.3, byte-identical to /usr/local/cuda-13.0's). Hazard: /usr/local/cuda-13.4 with cuBLAS 13.7.0.27 is installed beside it; an alternatives flip or LD_LIBRARY_PATH change moves gate logits silently except for the startup warn,lane::tests::cublas_versionandpad_classes. Dockerfile.cuda pins libcublas-13-0=13.1.1.3-1. - Branch (
cuda::branch): side Lane + two reused events, fork before the router gate GEMM, join before the combine, capture-safe; refuses a context with cudarc event tracking on. - Goldens:
hanzo-engine/tests/fixtures/zen6_moe/holds the recorded reference router, gate and shared-expert outputs.