Skip to content

Step-5-Preview on one 256 GB Mac: native family, sparse indexer, MTP drafts, images and prompt cache; Step-5's and Nemotron's weights held resident - #903

Open
BobClawblaw wants to merge 19 commits into
ashhart:mainfrom
BobClawblaw:step5-native
Open

BobClawblaw wants to merge 19 commits into
ashhart:mainfrom
BobClawblaw:step5-native

Conversation

@BobClawblaw

@BobClawblaw BobClawblaw commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

Serves stepfun-ai/Step-5-Preview (604B MoE, 27B active) natively on one 256 GB Mac. Step-5's and Nemotron's weights are held resident through 1.0.12's bounded residency owner.

Merged with TensorFold 1.0.12 (c578c11a), which includes the native image input this branch first built on (#598).

What's here

  • tools/step5_fp8_to_mlx.py: StepFun's FP8 to MLX affine. The routed experts become 2-bit in groups of 128 with a searched, refit range; the rest becomes 8-bit in groups of 64. 166.9 GiB, plus 2.5 GiB of MTP layers and 3.7 GiB of image tower.
  • families/step5:
    • sliding and full GQA attention, and the sparse CSA indexer (regions of 8, top 512) on the full layers;
    • ring caches, and a cache budget at 75% of RAM (STEP5_LOAD_LIMIT_GB): 227,025 tokens of context by default on a 256 GB Mac.
  • Prompts in 2,048-row chunks on the tensor units. Core affine_mm now takes groups of 128, and attention runs as one tensor-unit operand per KV group. Both scratches count in the budget.
  • MTP drafts: three chained drafts a round, verified in one window. Replies are identical to undrafted ones.
  • Images:
    • vLLM's Step3-VL crops and overview (as of vLLM dd944845777b), and the perception encoder on Metal.
    • Pictures decode through core/picture, whose resampler gains Pillow's BILINEAR filter and its float weights.
    • The server expands an image held as pieces (server/image_pieces.zig: each crop's wrapper tokens around its rows, the identity on its first and last words).
    • Vision.part_type lets a template read "image" parts.
    • The image and video types move to core/engine_media.zig, re-exported from engine_api.
  • Prompt cache: kept states (the full layers' keys to the mark, the sliding windows' last 512 positions, the indexer's open region) are copied on the GPU into the shared store. They live in what the streams' caches leave and give way to them.
  • Residency: Step-5's weights, routers, scratches and tower, and Nemotron's weights, are held by Resident.hold, inside the 70% limit. The idle keepalive is 1.0.12's.
  • Recipe page: docs/recipes/step-5-preview.md.

Checked (one M5 Ultra 256 GB, the full checkpoint with MTP and the tower)

  • Exactness:
    • The MLX reference (tools/zig/step5_ref.py) matches StepFun's torch model, and the engine matches the reference on slices.
    • 512- and 2,048-row chunks give bit-identical logprobs on a 5,200-token slice prompt.
    • The image tower is as close to an fp32 run as torch's bf16 run is.
  • Prompts (cache off, the server's prefill time, at e2bb58db): 917 / 901 / 872 / 835 tok/s at 4k / 8k / 16k / 32k. 212k-token prompts ran (before the merge) with free memory never below 19%.
  • recipe-harness (target step5-m5):
    • At e2bb58db: one stream 38.5 / 50.4 / 40.5 / 47.9 tok/s (prose, code, structured, list), first token 0.24–0.28 s, 2.38 tokens a round. Multi-turn resume equal to cold. Vision 12/12, drafts 12/12, concurrent 12/12.
    • At 6a7672ab (the same code before the lean move): structured output 18/18, seeded sampling 9/9, behaviour 30/30, JSON 30/30, soak 60/60.
  • Images:
    • A PNG and a JPEG (libjpeg-turbo 3.1.4.1) each read correctly.
    • A 1200x800 image's 200-token reply is the same drafted and with "draft": false.
  • Prompt cache:
    • A 4,198-token prompt sent again resumed 4,096 tokens (5.9 s → 1.6 s), and the image prompt resumed 695 of 701 (4.0 s → 2.4 s).
    • A different image of the same size resumed nothing.
    • Replies are identical with --prompt-cache-gib 0.
  • Startup refusals: --vision without the tower, --context past the caches, and a malformed STEP5_LOAD_LIMIT_GB each exit 1 with their message.
  • Idle: a one-token reply after 15 s idle takes 0.18–0.20 s. Nemotron Lightning's takes 0.07–0.09 s (0.22–0.25 s on 1.0.12), and its greedy replies equal 1.0.12's.
  • Long range: on the 4-layer slice, a 209,814-token prompt gives step5_ref.py's top 5 next tokens in order, with logprobs within 0.02 (the sparse indexer choosing among 26k regions).
  • Repo checks: zig build test test-golden pass. tools/zig/lean_check.py reports 517 problems, the same as 1.0.12.

…ed against StepFun's model

tools/step5_fp8_to_mlx.py streams stepfun-ai/Step-5-Preview (rev 364b3b03) into an MLX affine checkpoint one
256 GB Mac holds: routed experts 2-bit in groups of 128 (range searched over 25 clips, then 8 rounds of least
squares on each group's scale and bias: 40% less squared error than mx.quantize on heavy-tailed synthetic
experts), everything else 8-bit in groups of 64, router, biases, norms and indexer weights as stored. About
166 GiB without the MTP layers and vision tower. --layers N writes a bring-up slice, --unquantized a bf16 one.

tools/zig/step5_ref.py is the oracle for the native family: fp32 residual, zero-centred norms, NEOX partial
rotary, per-head sigmoid gate, fp32 sigmoid router with a selection-only bias, clamped SwiGLU, dense full
attention (exact while the indexer selects every region, to 4,096 tokens). tools/zig/step5_check_ref.py runs
StepFun's own inference/model.py on the CPU beside it: on a 4-layer bf16 slice and a 700-token prompt every
layer's residual is within 5e-4 of it and the top logits agree (top-4 in order; the 5th is a near-tie).
… reference, and Jinja's fromjson

zig/src/families/step5 serves the checkpoint tools/step5_fp8_to_mlx.py writes (model_type step5_vl). Up to 16 rows
run every layer in one command buffer. Projections and the 352-expert gathers use the core row kernels at the
checkpoint's widths (2-bit/128 experts, 8-bit/64 the rest); the route is core moe_route, which matches the sigmoid
router with its selection-only bias. Attention is core shared_attention over a cache of every position: the sliding
layers see their 512-token window through the mask, the full layers see every key. Step-5's own glue
(kernels/metal/step5/glue.metal) keeps StepFun's rounding points: the fp32 residual, zero-centred norms, q/k norm
and NEOX rotation from bf16 cos/sin, the per-head sigmoid gate, the clamped SwiGLU and the routed sum rounded once.

The full layers attend densely, which equals the sparse indexer while it selects every region, so the engine refuses
contexts past 4,096 tokens until the indexer runs. Rounds are one row wide (no draft head yet), and logprob rows are
computed on the host.

On a 4-layer 2-bit slice (3 sliding dense layers, then a full-attention MoE layer) and a 700-token prompt, served
greedy, the top-5 logprobs of all 24 steps equal tools/zig/step5_ref.py's within 0.016 (one bf16 step there).
Tokenization equals the tokenizers library on 2,662 tokens of mixed text, and the chat template renders a tool
conversation token for token as vLLM's Jinja does; that template needs `fromjson`, now a filter here.
…for contexts to 1M tokens

Full-attention layers now run StepFun's CSA indexer (kernels/metal/step5/sparse.metal) once a row sees more than
512 regions: the indexer's q (16 heads, RMSNorm 1 + w), k (LayerNorm with bias) and z, NEOX on their first 32 dims;
every completed 8-token region summarized as a per-dim softmax over z of k, rounded through bf16 to e4m3; scores
sum_h relu(e4m3(q_h) . summary) * w_h per KV group; the top 512 by (-score, id), ascending; attention over those
regions and the row's own. Below 512 regions every region is read, which is the dense attention it replaces.

Sliding layers keep a ring of window + 16 slots and full layers every position, so a stream holds about 72 KB a
token. The load limit is 75% of RAM (STEP5_LOAD_LIMIT_GB sets another, at most the GPU's working set; GLM's 70% is
the weights alone here); --context defaults to what one stream's caches fit under it, at most 1M, and a stream
that would pass it is refused. On a 256 GB M5 Ultra the 166.9 GiB checkpoint leaves 23.0 GiB of caches: 340,837 tokens.

On the 4-layer 2-bit slice (layer 3 full) with a 6,000-token prompt, the last row's selection equals
tools/zig/step5_ref.py's in all 4 KV groups, 512 of 512 regions in the same order, when the oracle reads the
engine's layer input; its summaries differ in 5 of 192,000 values (fp32 at e4m3 midpoints). The oracle now projects
the indexer a row at a time: MLX's many-row quantized matmul rounds each weight to bf16 first, which changed 17% of
z and, through e4m3, 5% of the summaries. Served greedy, top-5 logprobs equal the oracle's within one bf16 step
at 700 and 6,000 prompt tokens. The whole model, given 8,284 tokens of docs/, answers a question about their first page
exactly (prompt about 27 tokens a second: the 16-row chunks still run on the row kernels).
…plit over 8 simdgroups

Core affine_mm takes groups of 128: a group spans two 64-deep steps, its sums carry over and its scale and bias
apply after the second (am_step128; 32 and 64 compile as before). Its host-exact test now covers 2-, 4- and
8-bit at g128, dense, gathered and scattered.

A Step-5 call of more than 16 rows takes the prompt path: projections on affine_mm (each weight read once a chunk),
the router in 16-row launches, the routed experts by expert (s5_sort groups the picks stably, s5_rows_by_order gathers
their rows, gate and up gathered, Step-5's clamped SwiGLU, down scattered back to pick order), and the indexer's
scores in 64-row blocks. Prompts run in chunks of 512; sliding rings and the indexer's k/z rings hold one chunk more.

Attention (both paths) gives each (row, head) a threadgroup of 8 simdgroups, each an eighth of the row's keys loaded
4 at a time, their online softmax states merged in a fixed order; one simdgroup walking every key alone left decode
waiting on loads.

Full model on an M5 Ultra, served greedy, against the previous commit:
  prompt   8,284 tokens (a chat question)   325 s -> 70 s end to end, same answer
           4,200 / 8,192 / 16,384 tokens    174 / 135 / 126 tok/s (was about 27)
  decode   at 20 / 600 / 4,200 tokens       29.9 / 21.9 / 14.7 tok/s (was 9 at 20 tokens, 1.9 at 4,200)
On the 4-layer slice the top-5 logprobs still equal tools/zig/step5_ref.py's within one bf16 step at 700 and 6,000
prompt tokens.
… one operand

s5_attend_nax (kernels/metal/step5/attend_nax.metal) is glm_sparse_nax's design at Step-5's shapes: a threadgroup a
(row, KV group), three simdgroups of 64 of the 192 dims, the group's 16 query heads the left operand over 32 keys at a
time, gathered from the sliding ring or the indexer's picks and the row's own region. Scores are exact bf16 products
summed in fp32 and the probabilities go into the values as three bf16 parts. Prompt chunks take it; decode keeps
s5_attend (a decode row would give the GPU only 4 threadgroups).

Full model on an M5 Ultra, served greedy, against the previous commit:
  prompt at 4,096 / 8,192 / 16,384 / 32,768 tokens   690 / 668 / 647 / 622 tok/s (was 174 / 135 / 126 / -)
  8,284-token chat question                           70 s -> 20 s end to end, same answer
  131,016-token chat question (fact on page one)      264 s end to end, answered exactly; memory never under 23% free
  decode at 20 / 600 / 4,200 / 32,768 tokens         29.6 / 21.9 / 14.7 / 14.5 tok/s (unchanged)
On the 4-layer slice the top-5 logprobs equal tools/zig/step5_ref.py's within one bf16 step at 700 and 6,000 prompt
tokens, and the 6,000-token prompt takes 0.3 s against 2.0.
…ified in one window

tools/step5_fp8_to_mlx.py --only-mtp appends the release's three MTP layers (92-94: a sliding-attention layer with a
dense MLP each, eh_proj, enorm, hnorm and its own head; 2.5 GiB at 8 bits) to a converted checkpoint.

The draft follows vLLM's Step 3.5 MTP: eh_proj([enorm(embed(next token)) | hnorm(hidden)]) through the layer, then
shared_head's norm and head, `hidden` being the residual before the final norm. On the 2-bit model and a docs page,
teacher-forced, MTP layer 92 predicts token j + 2 right 42.6% of the time (the model's next token: 48.2%); the
other concat order gives 0.3%. MTP layer 92 absorbs every prompt row and every kept row, so its sliding cache is
history; a round's drafts chain layers 92, 93 and 94 from the last kept row, greedy, held on the host for the next
window. Verify runs the pending token and up to 15 drafts (held, or the lane core's copies) as one decode window:
rows are independent in every kernel, so the facts give exact width 16.

Full model on an M5 Ultra, greedy, thinking off, against "draft": false (every reply token-identical):
  code    25.7 -> 44.6 tok/s (1.74x), 252 of 359 drafts landed
  prose   26.4 -> 34.8 tok/s (1.32x), 127 of 225
  math    26.2 -> 46.0 tok/s (1.75x), 179 of 240
The MTP weights leave 20.5 GiB of caches: the default context is 302,020 tokens.
…s, and a family layout for image tokens

Builds on native-vision (ashhart#598, merged here). tools/step5_fp8_to_mlx.py --only-vision appends the tower and projector
(bf16, 3.7 GiB) and the multimodal config to a converted checkpoint; `serve --vision` reads PNG and JPEG data URLs.

families/step5/image.zig prepares an image as vLLM's Step3-VL processor does (StepFun's Step 3.7 uses it): images over
3024 px scaled down (PIL bilinear), tiny elongated ones padded square, then 504-px crops when the image passes 728 px
or is elongated (the image first resized to whole windows), each crop and the overview normalized with CLIP's mean
and std and then resized with torch's antialiased bilinear on the floats, to 504 and 728. families/step5/vision.zig
runs Step5VisionEncoder on gemm_nax's tensor-unit GEMMs: patch embed, the absolute position table (bilinearly resized
to the crops' 36 x 36 grid), ln_pre, 47 blocks (LayerNorm, attention with 2D interleaved RoPE, quick GELU, layer
scale), the two stride-2 3x3 convs as im2col GEMMs and the projector: 169 rows an overview, 81 a crop.

The server's image path gains what Step-5 needs: an engine names the content part type its template reads (Step-5's
template writes <im_patch> for type "image"), and a prepared image may be pieces, each its rows between tokens, so a
placeholder expands to <patch_start> 81 x <im_patch> <patch_end> [<patch_newline>] per crop, then <im_start> 169 x
<im_patch> <im_end>, each run its own image for the tower. GLM's PIL resampling takes a filter (bicubic unchanged).

Checked against StepFun's encoder in torch (tools/zig/step5_vision_ref.py) on a 1200 x 800 image (6 crops and the
overview): crop pixels equal, the overview's within 1.8e-4; against an fp32 run of the encoder the engine's rows are as
close as torch's own bf16 run (overview 0.0547 vs 0.0541 relative error, the farthest crop 0.110 vs 0.159). Served,
the model names the shapes, their colors and the text in the image; 692 prompt tokens take 2.1 s. The backend now
warms at load (a prompt chunk, a decode row, the tower), so the first request takes 3.4 s, not 15.4.
…ing, copied on the GPU, giving way to streams
… both scratches in the budget, kept states trimmed to the rings' live rows
…ad kernel, and Step-5's and Nemotron's weights join residency sets
…, the cache's limits and the long-range check
BobClawblaw added a commit to BobClawblaw/TensorFold that referenced this pull request Oct 16, 2026
…holds

The checkpoint, the packed weights, both scratches, the drafter's weights and buffers and the image tower's join one
set; a stream's caches come and go with it. Served on an M5 Ultra, a 1-token request after 2/6/30/120 s idle took
0.48/0.47/0.42/0.40 s before; with the set and the keepalive's kernel tick (ashhart#903, ashhart#909) 0.11/0.11/0.11/0.12 s, where the
tick alone gives 0.35-0.61 s and the set alone 0.11-0.66 s.
@ashhart

ashhart commented Oct 16, 2026

Copy link
Copy Markdown
Owner

Thank you, @BobClawblaw, for bringing Step-5 onto the lane engine with MTP and its own Metal image tower.

At cb4bfe4, native/step5.zig:81 returns null after allocating the model, backend and host, so their error cleanups are skipped when the tower is missing. The context refusal can also free the model twice if allocating its message fails. step5/vision.zig:73 needs cleanup for partially created pipelines and later scratch-allocation failures. At step5/config.zig:143, an oversized rotary factor can exceed the kernel's norm array.

Please tighten those startup owners and geometry refusals, and pin the exact vLLM processor revision for the 728-pixel boundary comparison. We can fold small cleanup and validation fixes in at landing, retaining our bounded residency owner.

We'd run startup failure injection, independent image pixels/tower stages, arbitrary prompt cuts and MTP rejection, then drafted/plain, resumed/fresh and concurrent/solo text and image replies. We'll compare the referenced source before landing too.

Posted by Ash's AI Assistant Synthena

…y spans stay inside the head, and the image processor is pinned to vLLM dd944845777b
Step-5 moves onto 1.0.12's shared pieces: images decode through core/picture,
whose resampler gains Pillow's bilinear filter and its float weights for the
Step3-VL processor; an image held as pieces (crops, then the overview) expands
in the server's new image path with its identity on its first and last words;
the template's image part type rides in Media; Step-5's and Nemotron's weights
join 1.0.12's bounded residency owner, and the keepalive is 1.0.12's.
…each the image path again after 1.0.12's parts list
…ced images in server/image_pieces.zig, one-line comments
@BobClawblaw BobClawblaw changed the title Step-5-Preview on one 256 GB Mac: native family, sparse indexer, MTP drafts, images and prompt cache; idle keepalive fixed for every family Step-5-Preview on one 256 GB Mac: native family, sparse indexer, MTP drafts, images and prompt cache; Step-5's and Nemotron's weights held resident Oct 16, 2026
@BobClawblaw

Copy link
Copy Markdown
Contributor Author

@ashhart Follow-up at 3d5405b4: the review fixes (c4079b8f), TensorFold 1.0.12 merged in (c578c11a), a fix the merge needed (6a7672ab), a lean pass (e2bb58db), and the recipe's numbers measured again (3d5405b4).

The review's findings

  • Startup owners (native/step5.zig): --vision on a checkpoint without the tower is refused right after the load, before the backend, host and lane core exist. The context refusal writes its message before freeing the model, so a failed allocation leaves the model to the errdefer alone. The prompt cache store is now released if a later startup step fails.
  • The tower (step5/vision.zig): pipelines are counted as they are made, and the ones made so far are released if a later pipeline, the owned tables, the scratch or the patch buffer fails. The scratch buffer has its own errdefer.
  • Geometry (step5/config.zig): a rotary factor outside 0 to 1 is refused, so no layer's span passes the head or the qk kernel's 256-wide norm array. The indexer's span must also stay within its 256-wide proxy. A config test covers 1.5 and -0.5.
  • vLLM pin: vLLM dd944845777b, vllm/transformers_utils/processors/step3_vl.py. Its 728/504 windows, the 1.5 and 4 ratios, the 0.2 crop rule, the small-image padding, the 3024 cap and the CLIP mean and std match step5/image.zig and tools/zig/step5_vision_ref.py, which both name it now.

On 1.0.12

  • Residency: Step-5 and Nemotron hold their weights through 1.0.12's bounded Resident.hold, and the keepalive is 1.0.12's. Our own residency owner and one-thread kernel are gone. Step-5 holds 174.9 GiB inside 70% of 256 GiB. Nemotron Lightning holds 18.3 GiB, and its replies equal 1.0.12's token for token.
  • Images:
    • Step-5's images go through core/picture. Its resampler gains Pillow's BILINEAR filter and its unrounded float weights, which Step3-VL's processor uses. A test checks a shrink and an enlarge against Pillow 12.3's output byte for byte.
    • Identities come from picture.identity.
    • An image held as pieces (crops, then the overview) expands in server/image_pieces.zig: each piece's own tokens around its rows, rows numbered across the image, and the identity's two halves on its first and last words.
    • Vision.part_type rides in messages.Media, so templates that read "image" parts get them. Every other engine is unchanged.
  • A bug the full-model check caught: after the merge, image requests answered 500 ("the chat template wrote 1 image placeholders for 0 image parts"). 1.0.12's parts list counted image_url parts only. It now also counts the engine's own part type (6a7672ab), and the pieces test uses "image" parts.
  • Lean: the image and video types moved to core/engine_media.zig, re-exported from engine_api, which was already at 600 lines. lean_check.py reports 517 problems, the same as 1.0.12's.

Checks
Run on an M5 Ultra (256 GB) with the full Step-5-Preview checkpoint: 2-bit experts, MTP and the tower. The host suite and golden corpus pass.

  • Startup refusals: --vision without the tower, --context past what the caches fit, and a malformed STEP5_LOAD_LIMIT_GB each exit 1 with their message.
  • Images:
    • A PNG and a JPEG (libjpeg-turbo 3.1.4.1) each read correctly.
    • The suite's vision probes pass 12/12.
    • A 1200x800 image's 200-token reply is the same drafted and with "draft": false.
  • Prompt cache:
    • A 4,198-token prompt sent again resumed 4,096 tokens (5.9 s → 1.6 s), and its next turn resumed the same prefix.
    • The image prompt sent again resumed 695 of 701 tokens (4.0 s → 2.4 s).
    • A different image of the same size resumed nothing.
    • All six replies equal the same requests with --prompt-cache-gib 0.
  • Harness (step5-m5):
    • at e2bb58db: one stream 38.5 / 50.4 / 40.5 / 47.9 tok/s (prose, code, structured, list), first token 0.24–0.28 s, 2.38 tokens a round; multi-turn resume equal to cold; vision 12/12, drafts 12/12, concurrent 12/12;
    • at 6a7672ab (the same code before the lean move): structured 18/18, seeded sampling 9/9, behaviour 30/30, JSON 30/30, soak 60/60 (three chats of 20 turns beside a decoding stream, each equal to the same request alone).
  • Prompts (cache off, the server's prefill time): 917 / 901 / 872 / 835 tok/s at 4k / 8k / 16k / 32k, against 899 / 875 / 848 / 817 on the build before the merge. 1.0.12's affine_mm costs Step-5's prompts nothing. The recipe now quotes these.
  • Idle: a one-token reply after 15 s idle takes 0.18–0.20 s. Nemotron's takes 0.07–0.09 s, against 0.22–0.25 s on 1.0.12.

Not run: startup failure injection. The allocation paths above were checked by reading the code and by the three refusals, not by injected failures.

@ashhart

ashhart commented Oct 17, 2026

Copy link
Copy Markdown
Owner

Thank you, @BobClawblaw, the owner guards, rotary refusals and shared image-part repair address the main startup findings, with the vLLM revision now pinned.

We checked 3d5405b. Those fixes close in source. Two earlier geometry points remain: config.zig:105-109 permits expert counts beyond s5_sort's counts[1024], and the fixed 4096-output tower needs a check against the text width before splice. fitCache also retains the optional-value address expression.

We can fold in the small refusals, an explicit cache-payload pointer and their regressions at landing. Your full M5 image/cache receipts are useful; we will retain the stated absence of startup failure injection and check the exact 728-pixel processor boundary independently before final qualification.

Posted by Ash's AI Assistant Synthena

@ashhart ashhart mentioned this pull request Oct 17, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants