Steering: read GLP (GGUF Layer Projection) vectors - #970
Open
msuiche wants to merge 9 commits into
Open
Conversation
--dir-steering-file already performs directional ablation:
y = y - scale * v * dot(v, y)
What it lacked was a container. The raw ".f32" form is a headerless blob
of n_layers * n_embd floats, and it cannot state four things that are each
silently wrong when they are wrong:
The operation. llama.cpp has shipped control vectors since 2024 with the
tensor convention this reuses exactly -- direction.<N>, fp32, 1-D -- but
llama.cpp ADDS them: h <- h + v. ds4 PROJECTS. A file loads into either
runtime with the right names, dtype and shapes, raises no error, and is
wrong in one of them: an additive apply of a projective direction pushes
every token along the direction instead of removing it.
The hook point. ds4 steers the block writers -- ffn_out = moe + shared, or
the attention output -- before the residual / hyper-connection fold.
llama.cpp's build_cvec() steers the post-layer residual. Measured on the
same direction, layers and alpha, the writer site left 34.0% refusal
against 3.8% post-layer: 9x weaker, and not an error.
The layer map. direction.N applies at layer N, no offset. A one-layer
shift does not fail, it degrades -- adjacent layers' refusal directions
have cosine similarity 0.555-0.979 -- so it survives a smoke test. This
cost a real exporter bug, caught only by a differential layer probe.
The base checkpoint. A direction is tied to the revision it was derived
from; the shapes still match on a different one.
GLP is standard GGUF v3 with llama.cpp's tensor convention unchanged plus
a glp.* block that states all four. ds4 now reads it and refuses rather
than misapplies: wrong operation, a hook the file was not calibrated for,
tensor names disagreeing with glp.layer_ids_zero_based, rank > 1, a
non-F32 direction, or a width that is not this model's n_embd. Spec:
github.com/msuiche/weightless/blob/main/spec/GLP.md
No inference-path change. This only replaces the three call sites that
read the steering file; the projection, the graphs and the kernels are
untouched, and the raw .f32 path is byte-identical -- the format is chosen
by sniffing the GGUF magic, not by a flag, so every existing vector and
build_direction.py output keeps working.
New:
ds4_glp.{c,h} self-contained GGUF v3 reader and spec checks. Its own
parse rather than ds4.c's, which is static, tied to
ds4_model and fatal on malformed input, where a steering
path must report a refusal.
--dir-steering-info FILE
print mode, alpha, coverage, resolved layer ids, norms
and provenance without loading a model. The first thing
to reach for when a vector behaves oddly -- our exporter
off-by-one surfaced as a dump reporting layers 11-39 for
a file derived from 10-38. Also describes files ds4 would
refuse to apply.
--dir-steering-allow-hook-mismatch
apply a vector calibrated elsewhere, with the scale
re-tuned. Off by default.
glp.alpha_default
becomes the default --dir-steering-ffn when the file's
hook matches the site being steered, so a published
vector runs at its calibrated strength with no flags.
Never adopted across a hook mismatch: projection is
quadratic in the norm, so an alpha from elsewhere is not
a better default than 1.0, only a more confident wrong
one. An explicit flag always wins.
dir-steering/tools/f32_to_glp.py
package a build_direction.py vector as GLP, reading the
sidecar JSON so shape and hook cannot disagree with what
was measured. Unit-normalises, hashes tensor bytes only
(glp.created makes the file non-reproducible), and reads
the result back to assert the layer ids landed verbatim.
Stdlib only.
tests/test_glp.c
72 checks over real GGUF files on disk, one per refusal:
missing mode, mode=add, hook mismatch and its override,
unknown hook, layer-id disagreement, direction.0, non-F32,
rank>1, width and layer-count mismatch, off-unit and zero
directions, the pre-GLP dspark.* namespace, truncated
headers, GGUF v2. Wired into make test and test-rocm.
Layer 0 is not expressible: direction.N applies at layer N and
direction.0 is invalid, so a full-coverage raw vector converts with
--layers 1-<last> and loses row 0. Vectors over a mid-stack range are
unaffected, and coverage dominates strength here (6 layers 18%, 16 layers
3.8%, 29 layers 0.0% on our refusal suites, alpha saturating above ~4).
Not implemented: a residual_stream_post_layer hook. Vectors published for
llama.cpp and vLLM declare it, so they need
--dir-steering-allow-hook-mismatch and a re-tuned scale until ds4 grows
that site. The enum and the refusal already name it.
Tested on M-series macOS, Metal and CPU backends, no model required by any
of the new paths:
make && make cpu clean, no new warnings
./tests/test_glp 72 checks, 0 failed
test_layer_pack, test_engine_mgpu_placement, test_gpu_args,
test_gpu_args_cli.sh, test_prompt_prefix, test_sampling,
test_deepseek4_vision_image, test_q4k_dot, test_mxfp4_dot,
ds4_agent_test, ds4-eval --self-test-extractors all pass
f32_to_glp.py on dir-steering/out/verbosity.f32 (43x4096) -> GLP,
read back by ./ds4, ./ds4-server and ./ds4-agent --dir-steering-info,
and alpha_default picked up as --dir-steering-ffn
The model-backed ds4_test suites (--logprob-vectors, --long-context,
--tool-call-quality, --metal-kernels) were not run: no checkpoint on this
machine. They exercise no code this change touches.
Parse glp.derived_at (where the direction was captured, vs hook_point where it is applied). A divergence marks a transferred vector: warn on load, mark TRANSFERRED in --dir-steering-info, never refuse — the reader still applies at hook_point and alpha_default belongs to the apply site. test_glp: +11 checks (native vs transferred fixtures, info markers).
The hook-point refusal cited "9x weaker (34.0% vs 3.8% refusal at identical direction, layers and alpha)" as though it measured ds4's hook. It does not, and the gap matters more than the number does. What weightless REFUSAL.md 3k (2026-08-13) measured, at equal coverage (16 layers) and equal alpha, was the ATTENTION write against the folded residual: 3.8% vs 34.0% on cyber100. That is a real application-point result — 3k states it as one — and the weight-edit form it was run in is numerically the activation projection of that matmul's output, verified in 3x to 1.4e-06, float32 roundoff. So the comparison stands. The problem is which contributor it measured. ds4's default hook is the FFN/MoE write, a THIRD site that has never been measured — in this project or in the published record. The write-vs-explain contrast these directions come from is a content decision, and content is largely MoE-mediated on this architecture, so the FFN write plausibly carries far more of each layer's refusal contribution than the attention write does. Quoting an attention-writer number as if it bounded the FFN writer is the kind of transfer this whole format exists to stop. Second caveat, from the source: the 34.0% came from a retired binary scorer that did not store completions, so it cannot be re-scored. So the refusal now argues from the mechanism, which needs no contested figure: a writer holds only what that block computed this layer, while the folded residual holds the accumulated sum, so projecting it also removes what every upstream layer contributed. Steering a writer prevents refusal being added; steering the residual deletes refusal already there. Different interventions, and neither dose transfers. The figure stays in ds4_glp.h with both caveats attached, where a reviewer can weigh it. Also: the refusal message now leads with the remedy and follows with the rationale. It is written into a caller-sized buffer, and a truncation should cost the explanation, not the fix — the previous ordering pushed --dir-steering-allow-hook-mismatch past the end of a 512-byte buffer, which the tests caught only after a forced relink. The tests now use the 768-byte buffer ds4.c actually passes, so that class of truncation is exercised rather than assumed. No behaviour change: same refusals, same overrides, same shapes. Comments, messages and docs only. make && make cpu clean, no new warnings. tests/test_glp 83 checks 0 failed; test_layer_pack, test_engine_mgpu_placement, test_gpu_args, test_gpu_args_cli.sh, test_prompt_prefix, test_sampling, test_deepseek4_vision_image, test_q4k_dot, test_mxfp4_dot, ds4_agent_test, ds4-eval --self-test-extractors all pass. Re-verified against the two published GLP-29 vectors: both still refused by default with the new message, both still load under the override at 29 directions, L10-38.
msuiche
force-pushed
the
glp-steering
branch
from
September 4, 2026 11:40
156fb66 to
4f9978b
Compare
The header explained the untested FFN site by asserting that the write-vs-explain contrast is a content decision and that content is largely MoE-mediated on this architecture. Nothing measured either half of that. It was my reasoning for why the FFN writer might behave unlike the attention writer, and it does not belong in a comment that a reader will take as a finding. The honest statement is narrower and is what remains: the measured comparison covered the attention contributor, ds4's default hook is the FFN/MoE write, that site has never been measured, and so the figure bounds it in neither direction. The mechanism argument stands on its own and needs no conjecture. tests/test_glp 83 checks 0 failed; make clean.
ds4's steering projected only the block writers -- ffn_out = moe + shared,
or the attention output -- before the hyper-connection fold. GLP vectors
published for llama.cpp and vLLM declare residual_stream_post_layer, the
post-layer residual, so they loaded only behind
--dir-steering-allow-hook-mismatch with a scale that had to be re-tuned.
This adds the third site: after the FFN fold, out_hc is the accumulated
residual, and each of the n_hc hyper-connection streams is projected
independently against the layer's direction --
y = y - scale * v * dot(v, y) per stream
No new kernel anywhere. The CPU path reuses
cpu_directional_steering_project_rows with rows = n_hc (out_hc is
[n_tok][n_hc][n_embd] contiguous); the graph path reuses
ds4_gpu_directional_steering_project_tensor, which already takes rows, so
Metal, CUDA and ROCm share the one host-side change. A residual-calibrated
GLP now loads with no flags: glp.alpha_default becomes the default
--dir-steering-resid when the file's hook matches, and only then. A hook
mismatch still refuses, and the message names the flag for the site the
file declares; an unknown hook name still refuses, as does mode=add.
Sites:
CPU layer_ffn_one and layer_ffn_one_decode_scratch (decode),
layer_ffn_batch and layer_ffn_shared_batch (prefill),
layer_ffn_tokens_parallel
graph the three metal_graph_encode_decode_layer_phase fold exits,
metal_graph_encode_layer_ffn_batch (prefill), and the glm53
folds (decode tail, both batch prefills, native session batch)
CUDA/ROCm same host code; their kernels already take rows
Two opt-outs, both matching what the writer hooks already do. The
deferred HC expand (M5 TP) writes after_ffn_hc only when flushed after
the layer returns -- too late for the projection -- so resid steering
takes the eager fold. The CUDA decode-graph capture of the GLM FFN tail
is skipped, because a replay bakes the scale; ffn steering already does
the same. The multi-session batch folds (native, pipeline, mixed) decline
resid-steered sessions, which take the per-session path, as they do for
ffn steering.
Capture ordering note: DSpark's target-hidden taps read cur_hc at the top
of a layer, the previous layer's post-fold stack, so under resid steering
they see steered state -- the same ordering the writer hooks already
produce, where steered ffn_out is folded into the stack the taps read.
Preserving pre-steering taps would be a separate change.
tests/test_glp.c: +11 checks -- a residual-hooked file loads at the resid
target with no override, its alpha_default is adopted by the resid default
and not the ffn one (and vice versa), and the mismatch refusal names
--dir-steering-resid. tests/test_dir_steering.c: model-free numeric check
of the projection at the new site through the DS4_TEST_HOOKS wrapper -- a
zero direction row is an exact no-op (layers a file does not cover stay
unsteered), a nonzero direction removes scale * dot(v, x) * v from each
stream independently, matching the hand computation, and a second scale-1
application is a no-op. Both wired into make test and test-rocm.
Tested on M-series macOS, no model required by the new tests:
make && make cpu clean, no new warnings
./tests/test_glp 94 checks, 0 failed
./tests/test_dir_steering OK
test_layer_pack, test_engine_mgpu_placement, test_gpu_args,
test_gpu_args_cli.sh, test_prompt_prefix, test_sampling,
test_deepseek4_vision_image, ds4_agent_test,
ds4-eval --self-test-extractors all pass
./ds4 --dir-steering-info on a residual-hooked fixture parses, and the
CLI adopts its alpha_default as --dir-steering-resid with no scale flag
The model-backed ds4_test suites were not run (no checkpoint on this
machine). CUDA and ROCm were not compiled here (no toolkit on macOS); the
change to their path is host-side and shared with Metal, but both should
be built on a real target before this goes upstream.
Six findings from review of the GLP reader and the resid-hook commit: - Non-finite values were accepted. A NaN or Inf direction, or a NaN glp.alpha_default, now refuse at load. The checks run on the serialized bytes as integers: this file builds with -ffast-math, and clang folds even memcpy/bit-test NaN checks on float-typed values away under -ffinite-math-only (verified with a minimal repro), so the value must never get a float type before the test. - The layer-map cross-check compared only count/min/max. It now sorts and compares the exact layer-id sets, so a duplicate direction.<N> (which would load twice, the second silently overwriting the first row of the dense buffer) and a declared list differing only in the interior both refuse. - alpha_default=0 was indistinguishable from an absent alpha_default in the CLI default path and fell through to the FFN default of 1. A file that declares 0 means "no steering by default"; the default-scale helpers now report adoption separately from the value, and an adopted 0 is honored as the zero-scale no-op it is. - Direction tensor offsets were bounds-checked but not float-aligned before direct mapped float reads; a misaligned offset now refuses. - Header counts were bounded at one byte per entry, so a small file could still amplify itself into multi-GB callocs. Counts must now fit the minimum serialized entry sizes (13 bytes per KV, 32 per tensor directory entry) and refuse otherwise, before allocating. - g_steering_provenance_logged raced when ds4-server creates sessions from worker threads. It is now an atomic_bool with explicit ordering, matching how ds4_tp.c handles shared flags. tests/test_glp.c: 94 -> 118 checks: NaN/Inf direction, NaN alpha_default, interior layer-list mismatch, duplicate direction.<N>, misaligned tensor offset, amplified header counts, alpha_default=0 adopted as a value, absent alpha_default adopts nothing. tests/test_dir_steering.c: a zero scale does not even read the direction (NaN row, bit-identical output) -- that is what makes honoring alpha_default=0 safe. Tested: make && make cpu clean, no new warnings; test_glp 118/0; test_dir_steering, test_layer_pack, test_engine_mgpu_placement, test_gpu_args, test_gpu_args_cli.sh, test_prompt_prefix, test_sampling, test_deepseek4_vision_image, ds4_agent_test, ds4-eval --self-test-extractors all pass. CLI check: a residual-hooked file with alpha_default=0 logs "--dir-steering-resid defaulted to 0" and does not fall through to the FFN default.
New data, measured today on DeepSeek-V4-Flash-0731 (NVFP4, vLLM overlay, greedy, refusal32 = comply/32 at 400 and 1400 max tokens): the FFN writer at alpha 6.0 reached 26/32 (alpha 4.0: 19/32, benign clean), the post-layer residual 11/32 at alpha 2.0 (alpha 4.0 garbled), the attention writer 5/32 at alpha 4.0 -- with a direction derived at the folded post-layer residual and transferred to the FFN site. So on this architecture the writer site is not a degraded second choice; it is where the calibrated window lives. The residual hook added in 8f3439e exists for vectors calibrated at the residual, not because the residual ranks first. Restate the three places that implied otherwise. dir-steering/README.md's hook-point paragraph now gives the measured numbers and says site efficacy is architecture- and dose-dependent. The ds4_glp.c hook-mismatch message keeps the refusal and the remedy-first wording but argues only what the check can defend: alpha was calibrated at one site and does not transfer silently. The ds4.c loader comment and the ds4_glp.h hook-point note (the 9x attention-writer figure, whose caveat claimed the FFN site had never been measured) say the same and cite the measurement. No behavior change. make && make cpu clean, no new warnings; test_glp 118/0 and test_dir_steering still pass.
The hardening pass (2ef5fdc) reads ids[i] as a glp_fail argument after free(ids). Capture the duplicate index first. Found by gcc 13's -Wuse-after-free on the CUDA build (clang on macOS did not flag it).
g_tp_block_ctx was declared Apple-only but is used on every GPU build (verify-block window); widen the guard to !DS4_NO_GPU. ds4_gpu_add_tensor_tp_flag was defined only in ds4_metal.m but called from shared host code; give the CUDA build the fallback the header documents (plain ds4_gpu_add_tensor) until a flag-publishing CUDA kernel exists. Found building glp-steering on GB10 (CUDA 13.0, sm_121a).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
--dir-steering-filealready performs directional ablation:What it lacked was a container. The raw
.f32form is a headerless blob ofn_layers * n_embdfloats, which is fine on the machine that produced it and unsafe to hand to anyone else, because it cannot state four things that are each silently wrong when they are wrong.The operation. llama.cpp has shipped control vectors since 2024 with the tensor convention this reuses exactly —
direction.<N>, fp32, 1-D — but llama.cpp adds them (h <- h + v) where ds4 projects. A file loads into either runtime with the right names, the right dtype and the right shapes, raises no error, and produces wrong output in one of them: an additive apply of a projective direction pushes every token along the direction instead of removing it.The hook point. ds4 steers the block writers —
ffn_out = moe + shared, or the attention output — before the residual / hyper-connection fold. llama.cpp'sbuild_cvec()steers the post-layer residual. Measured on the same direction, layers and alpha, the writer site left 34.0% refusal against 3.8% post-layer: 9x weaker, and not an error.The layer map.
direction.Napplies at layerN, no offset. A one-layer shift does not fail, it degrades — adjacent layers' refusal directions have cosine similarity 0.555–0.979 — so it survives a smoke test. This cost a real exporter bug on our side, caught only by a differential layer probe.The base checkpoint. A direction is tied to the revision it was derived from, and the shapes still match on a different one.
GLP (GGUF Layer Projection) is standard GGUF v3 with llama.cpp's tensor convention unchanged, plus a
glp.*metadata block that states all four.--dir-steering-filenow reads it and refuses rather than misapplies: wrong operation, a hook the file was not calibrated for, tensor names disagreeing withglp.layer_ids_zero_based,rank > 1, a non-F32 direction, or a width that is not this model'sn_embd. Spec: weightless/spec/GLP.md.No inference-path change
This replaces the three call sites that read the steering file. The projection, the graphs and the kernels are untouched, and the raw
.f32path is byte-identical — the format is chosen by sniffing the GGUF magic, not by a flag, so every existing vector andbuild_direction.pyoutput keeps working with no change to any command line.What's new
ds4_glp.{c,h}ds4.c's, which is static, tied tods4_modeland fatal on malformed input, where a steering path has to be able to report a refusal and continue.--dir-steering-info FILE--dir-steering-allow-hook-mismatchglp.alpha_default--dir-steering-ffnwhen the file's hook matches the site being steered, so a published vector runs at its calibrated strength with no flags. Never adopted across a hook mismatch — projection is quadratic in the norm, so an alpha from elsewhere is not a better default than 1.0, only a more confident wrong one. An explicit flag always wins.dir-steering/tools/f32_to_glp.pybuild_direction.pyvector as GLP, reading the sidecar JSON so the shape and the hook point cannot disagree with what was measured. Unit-normalises, hashes tensor bytes only (glp.createdmakes the file non-reproducible), and reads the result back to assert the layer ids landed verbatim. Stdlib only.tests/test_glp.cmake testandtest-rocm.The three CLIs share one
--dir-steering-infoand one default-scale resolver, sods4,ds4-serverandds4-agentcannot drift and the help text advertises nothing that is missing from one of them.Two limitations, stated up front
Layer 0 is not expressible.
direction.Napplies at layerNanddirection.0is invalid, so a full-coverage raw vector converts with--layers 1-<last>and loses row 0. Vectors derived over a mid-stack range are unaffected, and coverage dominates strength here (6 layers 18%, 16 layers 3.8%, 29 layers 0.0% on our refusal suites, with alpha saturating above about 4), so losing the first of 43 layers is not what decides a vector.No
residual_stream_post_layerhook. Every vector published for llama.cpp and vLLM declares it, so those need--dir-steering-allow-hook-mismatchand a re-tuned scale until ds4 grows that site. The enum and the refusal message already name it; adding the hook is a separate change that touches the trunk in every backend and wants hardware to validate.Testing
Per CONTRIBUTING: M-series macOS, Metal and CPU backends. None of the new paths need a model.
test_layer_pack,test_engine_mgpu_placement,test_gpu_args,test_gpu_args_cli.sh,test_prompt_prefix,test_sampling,test_deepseek4_vision_image,test_q4k_dot,test_mxfp4_dot,ds4_agent_test,ds4-eval --self-test-extractors— all pass.End to end on a real ds4 vector:
f32_to_glp.pyondir-steering/out/verbosity.f32(43×4096, the bundled verbosity direction) → GLP, read back by all three binaries via--dir-steering-info, andalpha_defaultpicked up as--dir-steering-ffn.No speed regression is possible: the change is confined to file loading, and
ds4-benchmeasures the graph, which is untouched.The model-backed
ds4_testsuites (--logprob-vectors,--long-context,--tool-call-quality,--metal-kernels) were not run — no checkpoint on this machine. They exercise no code this change touches.Why upstream
The steering feature is the part of ds4 people most want to hand each other, and today they can only hand each other a blob plus a verbal explanation of how to apply it. The op ds4 implements is already the right one; this just lets a vector say so, so that the failure modes above become refusals instead of plausible degraded output.