alexreinking/ggml app - #9417
Draft
alexreinking wants to merge 22 commits into
Draft
Conversation
alexreinking
force-pushed
the
alexreinking/ggml-app
branch
from
September 1, 2026 17:16
e836bfa to
b5b072a
Compare
alexreinking
force-pushed
the
alexreinking/ggml-app
branch
2 times, most recently
from
September 1, 2026 18:51
ccd899e to
b07cef9
Compare
alexreinking
force-pushed
the
alexreinking/ggml-app
branch
from
September 1, 2026 19:06
b07cef9 to
34fb2b9
Compare
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## alexreinking/struct-types #9417 +/- ##
=============================================================
- Coverage 69.73% 69.65% -0.08%
=============================================================
Files 262 264 +2
Lines 79988 80304 +316
Branches 19528 19580 +52
=============================================================
+ Hits 55778 55939 +161
- Misses 18296 18419 +123
- Partials 5914 5946 +32 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
hoist_invariants() looks for a single loop-invariant factor of the whole increment, so a sum of terms with different factors -- a*g(r) + b*h(r) -- had nothing it could do: flattening the multiply chain sees the sum as one opaque leaf, and the directive threw. Give each term its own accumulator instead, all advanced by one loop over the same reduction domain, with the write-back applying each factor once. Terms sharing a factor stay in one accumulator, so an increment that was already hoistable is unaffected. This is the TODO that was sitting on extract_factor(). Each term's accumulator is its own single-valued Func, not a value of one Tuple-valued intermediate, so it can be scheduled -- or severed by Pipeline::compute_offline() -- independently of the others. An update definition that did not split keeps the shape it had: a single intermediate carrying every value of the original reduction, whose values may reference each other's. hoist_invariants() therefore returns a std::vector<Func>: one entry per accumulator, or a single entry for a definition that did not split. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Reaching the terms of a *product* of sums needs the increment multiplied out first, and whether that pays is not something to guess at: it depends on what the terms turn out to contain. Splitting s * (r + 1) into two accumulators is a pessimization, while splitting (d*q(r) + m) * (e*p(r)) into d*e*sum(q*p) + m*e*sum(p) turns one float reduction into two integer ones. So multiplying out is its own schedule directive, Stage::distribute(), rather than a heuristic inside hoist_invariants(). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
compute_offline() severs a set of Funcs from a Pipeline's computation, rewriting every call to them (and anything only they depended on) into calls to fresh or caller-supplied ImageParams of matching type and dimensionality. This turns work that would otherwise be recomputed on every realize() -- e.g. a static weight quantizer's encode() step -- into ordinary input data supplied once, from wherever the returned `offline` Pipeline was realized or compiled. The rewrite is eager and destructive, like rfactor()/approximate_by(): by the time the call returns, none of the Pipeline's outputs depend on the severed Funcs for their computation, only for the shape the returned ImageParams must satisfy. Two overloads: one mints fresh ImageParams, one binds each severed Func to a caller-supplied ImageParam (e.g. a Generator's own Input). v1 requires each severed Func to be single-valued. Verified by test/correctness/compute_offline.cpp. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
approximate_by() eagerly and destructively replaces every call to a Func
inside a set of consumers with a call to the round trip
decode(encode(f)) -- a lossy, quantified Func-to-Func transform where
decode(encode(f)) reproduces f's signature. The substitution happens
immediately, like rfactor(), rather than being deferred to lowering the
way Func::in() is.
The transform is expressed as an Approximation: a bidirectional
encode()/decode() pair (src/Approximation.{h,cpp}), with combinators --
Compose, Apply, Permute, Choose, Identity, and the TrustedInverse escape
hatch -- for building up a codec from smaller pieces. See
doc/ApproximationDesign.md for the design rationale.
Also adds the Generator plumbing that lets a Generator wire the two
halves of an approximate_by()/compute_offline() split into real ports:
GeneratorBase::add_input(const ImageParam &) and add_output(const Func &),
backed by adopt() helpers on GeneratorInputBase/GeneratorOutputBase.
Verified by test/correctness/approximate_by.cpp.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Ports apps/ggml onto the mature quantized-kernels substrate: a from-scratch
Halide reimplementation of GGML's quantize / dequantize / vec-dot / repack
kernels for the full format catalog (Q4_0..Q8_K, K-quants, IQ*, TQ*, FP4,
f16/bf16), benchmarked against GGML's own kernels via kernel-bench. The
codecs are built as composable Approximation combinators (quant_components.h)
and spliced into pipelines with Func::approximate_by() /
Pipeline::compute_offline().
Wiring: add_app(ggml) + a "ggml" dependency and apps/vcpkg/ports/ggml overlay
port (ggml v0.15.3, CPU static). A minimal apps/ggml/vcpkg.json +
vcpkg-configuration.json make the standalone build the app's CMakeLists
already advertises actually resolve ggml (the integrated apps/ build still
uses the root manifest).
The app was written against the alpha rfactor-hoisting API, which was renamed
and split on the mature branch; the generators are migrated accordingly:
- inline_calls({...}) -> .update().eager_inline({...})
- rfactor(..., RFactorOptions::HoistInvariantFactor) ->
rfactor(...) + eager_inline(...).hoist_invariants() + change_type(...)
Known deviation: the SDOT vec-dot schedules (q4_0/q8_0/q5_0/iq4_nl/mxfp4) are
temporarily set to the correct Float schedule. The mature hoist_invariants()
cannot lift the per-block scale out of the reduction when the dequant is built
through approximate_by()'s round-trip replacement (a single eager_inline()
leaves the scale behind decode-chain Func boundaries), unlike
test/correctness/struct_type_dot_product.cpp which inlines direct dequantizer
Funcs. See the TODOs in symmetric_vec_dot_generator.cpp.
Verified: all 28 codec round-trip tests pass, and kernel-bench --all reports
zero correctness mismatches vs GGML across quantize/dequantize/vec_dot/repack.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adopts mature Type::Struct/field()/pack_struct() for the symmetric
quantize/dequantize codecs (Q4_0/Q8_0/Q1_0), replacing the hand-rolled
byte-layout engine for those formats:
- StructBlockLayout (quant_components.h): a struct-typed leaf that reads
the block's scale straight out of a typed `d` field (subsuming Fp16Pack's
manual lo|(hi<<8) reassembly + reinterpret) and hands the `qs` bytes to
the same code_pack the byte path uses. It replaces StructPack + Permute +
the scale's Fp16Pack; the compiler owns the field offsets and the total
byte size (block_type.bytes()), so no hand-summed block_bytes.
- make_symmetric_block_scheme() gains an opt-in struct_layout flag; the
symmetric *codec* generator sets it (vec_dot/repack stay on the byte path
for now). SchemeAndBytes carries the block's Type::Struct.
- codec_generator_base.h declares a 1-D Type::Struct ImageParam/Output when
the scheme is structured (vs the 2-D (byte, blk) UInt(8) buffer), and
compute_offline() binds/severs it as an ordinary struct-typed Func.
- ggml_quants.cpp: the Q4_0/Q8_0/Q1_0 wrappers hand GGML's raw bytes to the
struct kernel via a 1-D halide_buffer_t whose type is
{halide_type_struct, 8, reserved=block_bytes} -- Type::to_abi()'s exact
ABI form, constructed with Halide::Runtime::Buffer<void,1>.
Validated: q4_0/q8_0/q1_0 roundtrip tests pass and kernel-bench --all reports
zero mismatches vs GGML -- the struct-typed buffer flows through
approximate_by/compute_offline and the GGML byte-buffer ABI boundary unchanged.
Deferred: converting the vec_dot/repack operands (which would also eliminate
the re-derived block widths in symmetric_vec_dot_generator.cpp), and rolling
out to the affine (min), split-code (Q5_0), K-quant, and IQ layouts. The old
StructPack/FieldSpec/make_block_layout engine stays for those.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The SDOT integer-dot schedule was downgraded to a plain Float reduction in Phase 2 because hoist_invariants() could not find the per-block scale as a distributable factor: a single eager_inline() of approximate_by()'s .replacement only peels the outermost relayout wrapper, leaving the scale*codes product buried inside the dequantizer Func the Approximation combinators build. Fix: in the SDOT branch, after rfactor() preserves the block index, fold the *entire* decode chain of both operands into the per-block partial's update. eager_inline() no-ops on any Func not currently directly called and flattens exposed calls left to right, so inlining the whole set of inlinable decode handles -- one pass per possible chain level -- flattens it regardless of build order, leaving (codes*scale)*(codes*scale) with the scales as loop-invariant leaves. hoist_invariants() then lifts them and change_type(Int(32)) retypes the scale-free inner dot, which CodeGen_ARM matches to SDOT. Re-enables SDOT for Symmetric (Q4_0/Q8_0), Symmetric5Bit (Q5_0 -- its CombineBits code reconstruction is all inside the r.x-dependent codes leaf, so the scale stays a top-level factor), and the single-scale codebook families (IQ4_NL/MXFP4). Q1_0 stays Float: its 128-wide 1-bit block trips change_type(Int(32))'s overflow proof. Verified: kernel-bench --all reports zero mismatches vs GGML and all 28 roundtrip tests pass. q4_0 vec_dot now runs at 1.48x ggml-cpu (was 0.07x on the Float schedule) and q5_0 at 2.24x -- both beating GGML's hand-written kernels. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Factors the vec_dot SDOT schedule into a shared sdot_partial() helper (sdot_schedule.h): rfactor the block-index reduction, flatten every operand's decode chain (deep eager_inline so hoist_invariants() sees each per-block scale as a top-level factor), hoist the scales, and change_type(Int(32)) the scale-free inner dot. vec_dot now calls it instead of open-coding the sequence. Applies the same schedule to the repack matmul generators for the simple single-scale weight families (Q4_0/Q8_0/IQ4_NL/MXFP4): in gemv the weight scale depends only on (block, column) and the activation scale only on the block, so both hoist out of the within-block reduction, same as gemm. K-quant weights carry two-level (super/sub-block) scales that aren't a single per-block- invariant factor, so they keep the default schedule. Verified: kernel-bench --repack reports zero mismatches vs GGML. The simple families go from the default schedule (~0.06-0.20x) to ~0.6x of GGML's hand-written interleaved kernels for both gemv and gemm -- an efficient, SDOT-based schedule is now writable for repack, matching the vec_dot path. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Rolls the Type::Struct weight blocks from the codec path into the vec_dot
generator for the symmetric weight families (Q4_0/Q8_0/Q1_0):
- VecDotSpec carries an optional Type per operand; the base generator declares
a 1-D Type::Struct ImageParam (block index only) when set, vs the 2-D
(byte, blk) UInt(8) buffer otherwise, and indexes the reduction's block RDom
off the right dimension. compute_offline() binds/severs the struct-typed
operand as an ordinary Func.
- The symmetric weight uses make_symmetric_block_scheme(struct_layout=true) and
reports its block Type. The activation stays on the byte path for now (Q8_0/
Q8_1 are shared across many weight formats and the Reblock relayout is
byte-based), so operands are struct-typed independently.
- The Q4_0/Q8_0/Q1_0 vec_dot wrappers wrap GGML's raw weight bytes in the same
1-D struct halide_buffer_t helper the codecs use.
SDOT survives the struct decode unchanged: the base header's deep inline
flattens StructBlockLayout's field()-based dequantizer the same way it flattens
the byte path's, so hoist_invariants() still lifts the per-block scale.
Verified: kernel-bench --vecdot reports zero mismatches; q4_0 vec_dot runs at
1.55x ggml-cpu (struct-typed weight, unchanged from the byte-path SDOT result).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…s SDOT) LinearDequant dequantized as cast<float>(cast<int32_t>(codes)) * scale. The redundant int32 detour broke change_type(Int(32))'s SDOT rewrite pattern (cast<f32>(int8) * cast<f32>(int8)), so the repack gemv/gemm and codebook (IQ4_NL/MXFP4) inner products fell back to a float16 SIMD multiply instead of an int8 dot -- confirmed by `-e stmt`: widening_mul(float16x32, ...) vs vec_dot's widening_mul(int8x32, int8x32). Casting the int8 codes straight to float restores the SDOT match. Repack q4_0 gemv ~doubles (8.5 -> 14 GFLOP/s), IQ4_NL vec_dot 1188 -> 837 ns; 0 mismatches, 28/28 roundtrip tests still pass. Note on the remaining repack gap: this is the best Halide can do here. ggml-cpu matches the machine's `matmul_int8` (i8mm/SMMLA) instruction for its repack kernels (~166 GFLOP/s on q4_0 4x4), but Halide's CodeGen_ARM has no SMMLA path -- only SDOT (CodeGen_ARM.cpp:793-801). So a Halide repack tops out at SDOT throughput (~12x below ggml-cpu's SMMLA matmul), regardless of schedule; closing it would require i8mm support in the Halide backend. (vec_dot, a pure dot product, has no such gap -- ggml-cpu uses SDOT there too, and Halide matches/beats it.) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The SDOT vec_dot schedule collapsed each block's integer dot to a scalar
and accumulated it into a single float, which cost a horizontal reduce per
block and left the whole kernel bound by one nb-deep multiply-add chain.
Restructure it into ggml's shape:
- Take the accumulator's vector lanes from the within-block reduction, so
the sdot's four Int(32) lanes survive into the float accumulator and no
block pays for a horizontal reduce. Lanes have to come from r.x, not
r.y: blocks are interleaved {scale, codes} records, so a lane per block
would gather both the codes and the scales.
- Cut r.x so one sdot covers 16 int8s, with the chunks run serially into
the same register. Reducing straight to 4 lanes instead makes Halide
lower the wide reduce as two independent sdots plus an addp.
- Interleave four blocks into independent accumulators. Widening the
vector does not help here -- every lane of one accumulator advances on
every block, so only interleaving blocks shortens the chain.
- Give the main reduction a block count divisible by the interleave and
sweep the remainder in a second update. A predicated tail is not a
local cost: it makes the per-block sdot a dynamic-extent allocation
that has to be zeroed and accumulated through memory, roughly doubling
the cost of every block.
- Reduce the lanes-by-blocks accumulators vectorially rather than as a
serial chain of scalar adds.
vec_dot is called once per output element of a matvec, so the argument
marshalling is not amortized over anything; at these row lengths building
three Halide::Runtime::Buffers cost about as much as the dot product.
Fill a halide_buffer_t in place in the vec_dot wrappers instead, and build
these kernels with no_asserts/no_bounds_query.
Also pin the byte-path block stride, which was costing a serial pointer-add
chain per block, and add KERNEL_BENCH_N to the harness so per-call overhead
can be separated from per-block cost.
vec_dot vs ggml-cpu on M3 Max, n=4096: q4_0 0.52x -> 0.97x,
q8_0 0.61x -> 0.96x, q5_0 0.38x -> 0.51x. All 28 roundtrip tests pass and
kernel-bench --all reports no mismatches.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The affine formats decode to d*code + m, so the per-block product (d*code + m) * (d_act*act) has no single scale to hoist and the generator left them on the plain float reduction -- an unscheduled, fully scalar loop over every element, redoing the nibble extraction and both fp16 scale loads per element. Multiplying the product out with the new Stage::distribute() gives d*d_act * sum(code*act) + m*d_act * sum(act) and hoist_invariants() puts each term in its own accumulator. Both bodies are integer, so both reach SDOT: the first as the ordinary code-by- activation dot, the second as a dot with a vector of ones. That is ggml's own decomposition of these formats, except that ggml reads sum(act) from the s field block_q8_1 stores at quantize time rather than recomputing it, which is worth about four SIMD ops per block. vec_dot vs ggml-cpu on M3 Max, n=4096: q4_1 0.03x -> 0.80x (3843 ns -> 134 ns), q5_1 0.04x -> 0.50x (4059 ns -> 289 ns). All 28 roundtrip tests pass, kernel-bench --all reports no mismatches, and the odd-block tail is correct for block counts that are not a multiple of the interleave. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… sdot_partial() sdot_partial() was written against hoist_invariants()'s old single-Func return, so it retyped the intermediate and handed back one Func. Now that hoist_invariants() gives each term of a split reduction its own single-valued accumulator, thread that through: sdot_partial() returns a std::vector<Func> -- one Int(32) part per term -- and vec_dot_generator_base.h carries the vector through instead of narrowing it back down to one Func. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Where q4_0/q8_0/q4_1 landed and why, the build/measure workflow (including the traps: core-type noise, the generator's env var not invalidating ninja, WITH_TESTS=OFF), the design decisions behind distribute() and per-term accumulator Funcs, and the measured numbers for the stored-block-sum route that is still outstanding. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The affine vec_dots decompose (d*code + m)*(d_act*act) into d*d_act*sum(code*act) + m*d_act*sum(act). ggml does not recompute the second sum -- it reads the `s` field block_q8_1 stores at quantize time. Do the same: leave the activation decode un-inlined through distribute() and the first hoist so the offset term's accumulator is exactly the whole-block activation sum, change_type it to Float(16), and compute_offline it to a new third Input bound to a zero-copy fp16 view of the stored `s`. The product term re-inlines the activation's full decode chain and re-hoists to recover the scale-free Int(32) SDOT. q4_1 0.80x -> 0.97x (110.2 ns), q5_1 0.50x -> 0.64x. This matched the lane-split+sever projection, so the harder route (per-term rfactors via distribute-into-update-definitions) is unnecessary. All 28 roundtrips pass, kernel-bench --all clean, odd tails correct. Format knowledge lives with the codec: SchemeAndBytes::has_block_sums, set by make_symmetric_byte_sum_block_scheme, carried to VecDotSpec::act_has_block_sums (guarded a_nat == wbs). The GGML_PER_BLOCK_PROBE variant-A branch is kept for the symmetric formats. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The q5_0/q5_1 gap over q4_x is the per-element high bit unpacked from the qh field. PlanarBitPack's 1-bit decode emitted (qh[kk/8] >> (kk%8)) & 1, which lowers to a per-lane variable shift plus a transpose_vector to broadcast the qh bytes across the sdot lanes (~24 NEON ops/block). ggml avoids this with a byte->8-byte memory LUT (table_b2b). Mirror it with a compile-time Buffer<uint8_t> b2b(bit, byte) embedded in the binary. The table read is a contiguous 8-byte load only when the qh byte is a scalar and the 8 bit positions are the vector lanes, so materialize the reconstructed codes per block (combine_bits_code compute_at the block loop, kk split (byte, pos): pos vectorizes the load, byte unrolls to a scalar index). Inlined into the sdot the byte varies per lane -> a 16-wide gather (0.12x), so materialization is required. Two enablers: sdot_partial() gained a keep_out list to hold the codes leaf out of its deep inline (can_be_inlined() ignores compute level, so a schedule alone doesn't stop eager_inline); and the odd-block tail gets its own decode chain (a second Wt/Vec + approximate_by bound to the same inputs) so the main reduction's compute_at can fuse without the tail -- which also stops Halide hoisting the codes buffer to whole-row. q5_0 0.51->0.63x, q5_1 0.64->0.68x; q5_K on the float path 8615->6730 ns from the shared decode. Transpose is gone (no uzp2/dup.8b). All 28 roundtrips pass, kernel-bench --all clean, odd tails correct. The residual gap to 0.95x is the per-block codes store/reload, which needs a CodeGen_ARM change or ggml's hand-scheduled load balance -- see PERF_NOTES. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…x, 0.68->0.78x) Two fixes on top of the qh LUT: - q5_0_vec_dot / q5_1_vec_dot were missing FEATURES no_asserts no_bounds_query (every other tuned vec_dot has it), so they paid the assert/bounds-query prologue -- ~11 ns of startup on a ~170 ns call. Worth ~0.04x on its own. - The materialized reconstructed codes were round-tripping a per-block stack buffer. store_in(MemoryType::Register), with kk split into 16-code units (one sdot chunk = two qh bytes x 8 positions) so the table-load store width matches the sdot read width, keeps them in the vector register file (stmt: "in Register", str drops to 2). q5_0 0.63->0.69x (166.6 ns), q5_1 0.68->0.78x (170.3 ns). All 28 roundtrips pass, kernel-bench --all clean (24/24), odd tails correct. The residual gap to 0.95x is the reconstruction op count (ggml's table folds the +16/-16 into one sub) and qh load traffic -- see PERF_NOTES. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
q5_0_vec_dot still built three Halide::Runtime::Buffer objects per call (~13 ns of marshalling on a ~160 ns kernel) while q4_0/q8_0/q5_1 fill a halide_buffer_t in place via StackBuffer. Migrate it. q5_0 0.69->0.73x. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
alexreinking
force-pushed
the
alexreinking/ggml-app
branch
from
September 3, 2026 17:10
34fb2b9 to
f94a1e2
Compare
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.
Fixes #
Breaking changes
List any breaking changes here. If there are none, you can remove this section.
Common breaking changes include:
Halide::namespace, but not theHalide::Internal::namespace.output.
These do not necessarily disqualify a PR from being merged, but they should at
least be tagged with the
release_noteslabel.Checklist