Repository navigation
Add tensorfold merge: fold a PEFT LoRA into a quantized checkpoint - #710
philip-pentatonic wants to merge 4 commits into
Conversation
quantizeMlx quantizes a dense tensor to MLX affine 4-bit with groups of 32, 64 or 128 the way MLX does: float32 arithmetic with a true division, the bias at the larger-magnitude edge of a group, a signed scale chosen so that zero is a grid point, round half away from zero, codes taken from the unrounded scale and bias, and bf16 rounding only for storage. When the dense source and the conversion scheme match how a checkpoint was made, it reproduces that checkpoint's words, scales and biases exactly. A non-finite value, an overflowing range, a zero adjusted scale and a scale or bias that rounds to a bf16 infinity are refused, naming the group. `tf-merge-check --quantizer DENSE SHIPPED MODULE...` quantizes each named dense tensor and compares it with the shipped one, reporting the share of equal scales, biases, codes and whole groups. The dense tensor must be an n x k f32, bf16 or f16 matrix of the shipped projection's shape and payload size, every required input must be a regular file, and a failed allocation unwinds cleanly. Tests pin the quantizer to nine groups produced by an independent float32 implementation of the published algorithm: two where true division and reciprocal multiplication differ, one that needs the unrounded scale and bias, a q0 tie at -8.5, the larger edge at the minimum and at the maximum, a negative flat group and a tiny group with q0 = 0; they also check a local reciprocal variant disagrees with them. Other tests cover group sizes 32, 64 and 128, the refusals above, and the check on synthetic dense and shipped folders. Build: `zig build test-merge`, `zig build tf-merge-check`.
…LoRA into a quantized checkpoint `tensorfold merge DENSE ADAPTER OUT --quantize SHIPPED` merges a PEFT LoRA into the dense (bf16) weights of only the tensors the adapter names, quantizes those fresh with the MLX quantizer at the shipped tensor's bits and group, and copies every other tensor, shard and index byte for byte from the shipped MLX affine 4-bit checkpoint. Folding an adapter into already-quantized weights keeps little of its effect, so the dense base is required. Host-only. The adapter's B*A is accumulated in FP64 in order and scaled once; a zero sum gives an exactly zero delta, and a sum that is not exact in FP64 is not made exact. The merge is staged in a sibling folder and renamed to OUT only after every projection has succeeded, so a failure before publication leaves no OUT; the rename replaces an empty OUT atomically and never a non-empty one. It refuses, naming the tensor: other modules (only the Nemotron mixer in_proj/out_proj and shared experts' up_proj/down_proj and the Qwen dense q/k/v/o_proj are served), other bit widths, DoRA and rsLoRA, an adapter r or alpha out of range, duplicate PEFT keys, shard names that leave the folder, subdirectories, dangling symlinks and special files among the inputs, shards whose tensor ranges overlap, non-finite weights or factors, and mis-shaped tensors. tf-merge-check DENSE ADAPTER OUT SHIPPED gates a merged folder: the exact file set and lengths and every byte outside the touched tensors identical; every written group equal, byte for byte, to a recomputation from the dense weights and the adapter, with the number of groups compared reported; aggregate RMS error at most 1.05x the shipped quantization's, both against an FP64 reference. The worst-column ratio is printed but not gated, being an extreme of two differently referenced tensors. An adapter with no effect (alpha 0, an all-zero factor, negative zero included) must leave every byte and give ratios of exactly 1. The recomputation shares the merge's arithmetic, so it checks that what was written is what the code computed; the independent evidence is the pinned vectors, including one merge with a non-power-of-two alpha/r, and the quantizer's identity with shipped tensors. Tests use synthetic folders: a zero adapter reproduces every file, a random adapter changes only the named tensors, each refusal names its tensor, a failure on the second projection or at any allocation leaves no OUT, a non-empty OUT that appears before publication is never overwritten, and the gates fail on appended bytes, extra files, a wrong group and a collapsed grid.
The merge no longer admits modules by a list of name suffixes. A module is merged when the shipped checkpoint holds its .weight, .scales and .biases as rank-2 tensors in the 4-bit affine layout and the dense source holds a rank-2 .weight of the same shape, so attention, MLP and linear-attention projections qualify alike. Anything else is refused naming the module and the reason: a module not quantized in the shipped checkpoint (a norm or an embedding), a stacked or rank-3 expert tensor, or a module the dense source lacks. Every other check is unchanged. A dense module name is resolved to the shipped one by the exact name, then by one table of known prefix rewrites (model.language_model. to language_model.model.), so a dense source and an MLX conversion that name the same tensor differently still match. A module that resolves under no candidate, or under more than one, is refused naming each name tried. The leaves are read and written under the shipped name. Tests use synthetic folders: MLP and linear-attention names are merged, the prefix mismatch is resolved and passes the gates, and a stacked expert tensor, an unquantized norm, a module absent from the dense source, an ambiguous name and an unresolvable name are each refused with their reason.
…her namespace The aggregate-RMS comparison with the shipped quantization is no longer a gate at 1.05. A delta that widens a group's range legitimately coarsens that group's grid, so on sound merges of real adapters the ratio read 1.0518 and 1.0515 against that limit; it measures the adapter, not a defect. Both RMS ratios and the worst-column ratio are printed, and the check fails only when the merged RMS error exceeds twice the shipped one, a net for a gross fault such as a wrong scale or a sign error and not a quality gate. The deterministic gates are unchanged: the exact file set and lengths, every byte outside the touched payloads, every written group equal to its recomputation, and exact identity for an adapter with no effect. The test of the 1.05 limit is replaced by one where 1.06 and exactly 2.0 pass and 2.01 fails. A module may now be given in either namespace. The exact name is tried first, then the table of prefix rewrites forwards and in reverse; the pair of names must be present in both the dense source and the shipped checkpoint. A name that resolves to no pair, or to more than one, is refused naming every pair tried, and the quantizer check prints which form matched. The merge resolves adapter module names the same way. Tests cover a module resolving from either side, an ambiguous name, an unresolvable name, and an adapter naming the shipped spelling.
|
Pushed two follow-up commits on top of the first (not a rewrite, so the earlier review stays valid):
The description is updated with the receipts for both models: Nemotron 3.5 Lightning (as before) and now Qwen3.8-27B, where the zero adapter reproduces all 17 files of the shipped checkpoint byte for byte and a random adapter over 15 linear-attention, attention and MLP modules matches its recomputation on all of about 12.9 million groups. Not run: serving a merged folder on any engine, Metal and macOS builds. |
|
An end-to-end run with a real trained adapter, on a model small enough to also check at the model level. Setup: dense Model level, on 20,480 positions of English text, KL(unmerged adapter on the bf16 base || variant), the merged weights read back from the tool's own output:
Per sequence the merged model is 2.6 to 4.3 times closer (median 3.4). A separate PyTorch implementation of the same quantizer gives 0.0802, and the tool's weights equal it exactly on the modules I compared. One adapter and one 4B model, so a measurement, not a rate. Two caveats a reviewer should know:
|
Part 2 of 2 for #666. Depends on the quantizer pull request (#709); review only the last three commits (
d97a5472the merge command,d5b63df6which modules and names,14a1ad93the sanity bound and namespaces). The closing note of #160 ("a fine-tune merged into the weights and quantized in the same format serves today as its own model") is what this makes possible without Python or MLX.What this changes
tensorfold merge DENSE ADAPTER OUT --quantize SHIPPEDmerges a PEFT LoRA into the dense bf16 weights of only the tensors the adapter names, quantizes those fresh with the quantizer from the first pull request at SHIPPED's bits and group (32, 64 or 128), and copies every other tensor, shard and index byte for byte from SHIPPED. The work happens in a sibling staging folder that is renamed to OUT only after every projection succeeded.tf-merge-check DENSE ADAPTER OUT SHIPPEDgates the result.Which modules are merged is read from the checkpoints, not from a name list: a module is merged when SHIPPED holds
<module>.weight,.scalesand.biasesas rank-2 tensors in the 4-bit affine layout and DENSE holds a rank-2.weightof the same shape. That covers attention, MLP, linear-attention and Mamba mixer projections alike. Anything else (a module not quantized in SHIPPED, a norm or embedding, stacked rank-3 expert tensors, DoRA, rsLoRA, duplicate PEFT keys, non-finite or mis-shaped tensors, out-of-rangeroralpha, a non-regular file, an overlapping shard layout) is refused with the module and the reason. A module may be named in either namespace: the exact name first, then one small table of known dense-to-shipped prefix rewrites (model.language_model.andlanguage_model.model., in either direction), refusing when none or more than one distinct pair resolves and listing every pair tried. About 60% of the diff is tests and fixtures.Why the dense base: folding an adapter into already-quantized weights loses most of it. On one real rank-64 adapter (alpha 16, a 4B Qwen model, attention and MLP projections), measured from the weights alone and then on 20,480 positions of English text: the adapter's change is 2 to 4% of one 4-bit step per weight; merging it into the quantized grid recovers about a quarter of the divergence from the unmerged adapter, and merging into the dense weights and quantizing fresh lands at the plain 4-bit model's own noise floor (KL 0.080 against 0.274 for ignoring the adapter and 0.231 for merging into the grid). One adapter, so a measurement, not a rate.
Choices a reviewer may want to weigh in on:
B*Ais accumulated in FP64 over the rank and scaled once byalpha/r(the way PEFT applies it); an exactly zero sum gives an exactly zero delta. Exact cancellation of every factor matrix is not claimed.alpha/r), both produced by a separate float32 implementation, and the byte identity against shipped tensors.Receipt
e7c523ebplus these commits, Zig 0.17.0, Linux x86_64 (AMD Threadripper PRO, 32 cores), host only, no GPU. Every shipped file's sha256 was checked against the Hub; dense tensors fetched by HTTP range from the public repos.TensorFold/NVIDIA-Nemotron-3.5-Lightning-30B-A3B-MLX-4bitatd9d758fb(14 files, 18 GB), densenvidia/NVIDIA-Nemotron-3.5-Lightning-30B-A3B-BF16ata9904d24; adapters rank 16,alpha32, bf16 factors,in_projandout_projof layers 0, 2 and 30.tensorfold-native merge27 s, 0.4 GB peak;cmpof all 14 files: 0 differ;tf-merge-check:identity: 14 files byte-identical, 0 touched: ok,merge check: all gates pass.TensorFold/Qwen3.8-27B-MLX-4bitat22d8d538(3 shards, 15 GB), denseQwen/Qwen3.8-27Bat1d4bf0f2, which name the same tensors differently (model.language_model.layers.N...andlanguage_model.model.layers.N...). 15 modules across layers 0 and 3: linear-attention (in_proj_qkv/z/a/b,out_proj), attention (q/k/v/o_proj) and MLP (gate/up/down_proj).tf-merge-check --quantizer: scales, biases, codes and whole groups 100.0000% identical on all 15, given shipped-style names.cmpof all 17 files: 0 differ;merge check: all gates pass. Merge 49 s, 2.4 GB peak; check 2 min 28 s, 10 GB.1 of 491520 groups differ from OUT(27B),1 of 172032(Nemotron),merge check: FAILED.zig build test-merge: 80/80 tests (cold build, 12/12 steps).zig build test: 39/39 steps, 171/171 tests.zig fmt --check: clean.python3 tools/zig/lean_check.py: 534 problems before and after. No source file over 600 lines.Checklist
"draft": false, a resumed prompt equals a fresh one, and concurrent equals solo. Not applicable.zig build test.