sycl: add PTQ1_0 and PQ2_0 support with MMVQ vector dot kernel - #235
Conversation
- Implement base-3 trit unpacking and dequantization (dequantize_ptq1_0, dequantize_pq2_0) in dequantize.hpp and convert.cpp - Implement vec_dot_ptq1_0_q8_1 and mul_mat_vec_ptq1_0_q8_1_sycl in vecdotq.hpp and mmvq.cpp - Add get_rows and cpy dispatch for PTQ1_0 and PQ2_0 - Enable PTQ1_0 in can_use_mul_mat_vec_q() for fast in-register Level-Zero vector execution - Verified on Intel Arc Pro B50 with oneAPI 2026.1 / Level-Zero (2.20 tok/s on 27B)
Performance Update: SIMD Vectorized PTQ1_0 Unpacking + DP4AReplaced the serial recurrence loop in Benchmark results on Intel Arc Pro B50 (16GB, SYCL / Level-Zero):
Exhaustive integer validation passed on all 8,192 byte configurations with zero mismatches against CPU reference. |
Performance Update: Native PQ2_0 MMVQ Vector Dot KernelImplemented 4-thread cooperative DP4A vector dot kernel for Benchmark results on Intel Arc Pro B50 (16GB, SYCL / Level-Zero):
|
Data point: Intel Arc B390 iGPU (Panther Lake Xe3), SYCL / Windows — builds and passes, and it moves a pre-existing abort downstreamAdding an integrated Battlemage-era part alongside the discrete Arc Pro B50 in the description. Built with oneAPI 2025.3 + MSVC 2022 Build Tools, Ninja, Release: 360/360 targets, clean. Worth stating explicitly since this PR's only CI job is Compared
|
prism 422590f5d |
#235 c5f6c9718 |
|
|---|---|---|
| cases OK | 90 | 171 |
| FAIL | 0 | 0 |
| PTQ1_0 | 0 | 9 OK |
| PQ2_0 | 0 | 9 OK |
| run ends | mmvq.cpp:2562: fatal error: unsupport data type=pq2_0 |
mmvq.cpp:2710: fatal error: unsupport data type=tq2_0 |
So on this device stock prism aborts the MUL_MAT suite on PQ2_0; with this PR, PQ2_0 and PTQ1_0 both execute and match the CPU reference, and the abort moves on to the next type that has no mmvq kernel. That is a strict improvement, and the new kernels are correct on every case that ran.
GET_ROWS likewise: 8 cases (4 PTQ1_0 + 4 PQ2_0) go from not supported to OK. The full-suite totals account for exactly that — 658 OK / 78 not-supported on prism, 666 / 70 here.
The abort class is worth fixing separately
Both endpoints above are the same shape: can_use_mul_mat_vec_q() gates on
return ggml_is_quantized(src0->type) && ...so the backend claims every quantized type, and mul_mat_vec_q_switch_type then GGML_ABORTs on any type it has no case for. That is why prism dies on pq2_0 and this branch dies on tq2_0 — this PR fixes two instances of the bug without changing the pattern, so the next quant type added will land in the same trap.
Gating on the set of types actually implemented (or returning false instead of aborting) would turn a hard crash into a normal fallback. Not a blocker for this PR, but it is the reason a full test-backend-ops run cannot complete on SYCL on this box.
Unrelated and also pre-existing: the full suite aborts identically on both builds at set_rows.cpp:549: Unsupported tensor type!, same mechanism.
Caveat
Because both runs abort partway, the PTQ1_0/PQ2_0 coverage here is only the 9 MUL_MAT cases per type that the stock suite generates before the abort. #188 adds Bonsai-shape MUL_MAT/MUL_MAT_ID cases (k = 1024/5120/6144/17408, odd row counts 67/70, n = 1..8) for exactly these two types on the Vulkan side; porting those test cases over would give this PR's kernels much better coverage than they currently get.
|
Human here. I can help with testing on hardware later tonight if that helps. LG Gram 17 w/ Lunar Lake, 32gb unified memory. |
|
Follow-up on the same machine (Arc B390 iGPU, oneAPI 2025.3, Windows) — end-to-end this time, and the before/after is stronger than the On stock Hard abort during load, both bands. With this PR @
So the practical before/after here is not a speedup ratio — it is "aborts on load" to "runs correctly", which is a better headline for this PR than the case counts I posted above. Caveat on those numbers: this build prints One thing that may be worth a look: on the same box and the same two files, the Vulkan backend with PQ2_0 support (#188) gets tg128 11.56 on |
|
Correction to the caveat on my previous comment, in the direction of the numbers being more trustworthy, not less. I flagged those results as a floor because the build printed I rebuilt from a clean directory with the flags set explicitly (
No material change — flat to marginally lower, within run-to-run spread. Which is what you would expect for a GPU-bound workload: the device kernels are compiled by the SYCL device compiler regardless of the host-side flags, so unoptimized host code barely registers here. Consequences for what I wrote earlier:
Apologies for the noise — I would rather re-measure and confirm than leave a hedge attached to a number that turned out to be fine. |
The MMVQ kernel derives its lane split from qi/vdr. PQ2_0 was passing QI_PQ2_0 (= QK_PQ2_0/32 = 4), which put only 4 lanes on each 128-element block and left every lane an 8-deep serial dp4a chain over 8 bytes of qs. Pass a dedicated MMVQ_PQ2_0_QI of 16 instead, so a block is covered by 16 lanes reading 2 bytes / 8 elements each with 2 dp4a per call, and a sub-group keeps more blocks in flight. Measured on an Arc iGPU with test-backend-ops perf, MUL_MAT m=4096 n=1 k=14336, alternating the two builds to cancel out background load: 827/846/837 us before, 397/395/384 us after (~2.1x). On an idle machine the same case goes 247.6 -> 123.0 us. 8 and 32 lanes per block were both tried and were slower; at 32 the per-call scale setup dominates the single remaining dp4a. Also fold the 2-bit-to-byte-lane unpack shared with the q2_0 path into unpack_2bit_to_byte_lanes(), and switch the epilogue to the q4_0-style sumi*ds.x - zp*ds.y form. The previous PQ2_0 epilogue applied the zero point with byte_sub_4 and then used only ds[0], dropping the ds.y term. test-backend-ops test -o MUL_MAT -p type_a=pq2_0: 38/38 on both SYCL devices, 3/3 backends passed. The unrelated q1_0 failures on SYCL1 are present with and without this change.
bri-prism
left a comment
There was a problem hiding this comment.
Reviewed at c5f6c97. This fills a real hole: at tag prism-b10709-9a9394a the SYCL backend had zero references to either format, so ternary models had no native path there at all. Having actual Battlemage silicon behind the numbers is worth a lot, since nobody else working on these formats has Intel hardware.
One finding that is not a defect in this PR but that I think changes how you read your own throughput number, then two smaller things.
The Hadamard transform is falling back to a dense mat-mul on SYCL, and this PR cannot see it.
I read the published Ternary-Bonsai-2-27B-PTQ1_0.gguf header directly. It carries:
prism.hadamard.block_size = 1024
prism.hadamard.transform = normalized-sylvester-walsh-hadamard
prism.hadamard.weight_names = <401 strings>
ggml/src/ggml-sycl/fwht.cpp dispatches only widths 64, 128, 256 and 512, and returns false on anything else. At 1024 it returns false on every call, and the caller falls through to the ordinary mat-mul dispatch. That path is numerically correct, because llama_mul_mat_hadamard in src/llama-impl.h builds the matmul against a materialized rotation tensor rather than a placeholder, so you are getting right answers. You are getting them by running a dense 1024x1024 multiply in place of an O(n log n) transform, roughly a hundred times the arithmetic, on each of 401 folded weights per token.
For comparison, the CUDA, Metal and Vulkan backends all carry the full eight widths from 64 to 8192. SYCL is the only one capped at 512, and this PR does not touch fwht.cpp.
I raise it because 2.20 tok/s is the number a reader will attribute to your MMVQ kernels, and a good part of it is probably this instead. Before you tune the dot products further it is worth checking how much of the frame is going into those dense multiplies. If it is what I expect, the wide SYCL FWHT kernels are a larger win than anything left in the vector dot.
Worth coordinating: someone on our side has started writing SYCL wide-kernel FWHT for exactly this gap. I will make sure they see this PR so the two do not collide, and so they know there is Battlemage hardware that could validate their work, which is the thing they currently lack.
The verification is a coherence check, not a correctness gate.
"Verified output coherence and deterministic evaluation with llama-simple" establishes that the model is not producing garbage, which is a real milestone for a new backend. It does not establish numerical agreement, and coherent output coexists comfortably with meaningful quality loss, particularly on a 2-bit format where a subtly wrong dequant still yields fluent text.
Could you add test-backend-ops -o MUL_MAT filtered to PTQ1_0 and PQ2_0 on SYCL0, with the case counts, and ideally a logit or perplexity comparison of the same prompt against the CPU backend on the same file? The case counts matter as much as the pass, since a backend that silently declines to support a shape reports a green run over a smaller population than you think.
Your summary undersells the PR.
The description says PTQ1_0 was enabled in can_use_mul_mat_vec_q(), which reads as though PQ2_0 got dequant and a kernel but no dispatch. In the actual diff both are dispatched, at mmvq.cpp:2464 and :2478, with single and multi-column paths for each. Worth correcting so a reviewer does not go looking for dead code as I did.
Smaller notes.
The GGML_ABORT on unsupported ncols_dst in both multi-column dispatchers is the right call over a silent fallback, and I would keep it.
The numbers are one configuration on one device. 2.20 against 0.89 for naive dequant is a strong relative result, but there are no prefill figures and no second device. That is fine for landing a first implementation, just worth not generalizing to Arc as a whole in the PR text.
Method note: source reading at c5f6c97, plus GGUF header reads over HTTP range requests against the published artifact. I have no Intel GPU and ran nothing.
|
Correcting my own review above. The FWHT point is real as a fact and wrong as a conclusion, and I would rather retract it clearly than leave you chasing it. What I got wrong. I said the width-512 cap in At The measurements already in this thread say the same thing, and I should have read them before writing my review rather than after. On the same box and the same files, PTQ1_0 is 13.16 tg128 on Vulkan, which has the full 64-through-8192 FWHT, against 11.68 here with the cap. A ~13% gap across two different backends bounds the FWHT contribution well below what I implied. If the fallback were dominating, that gap would not be 13%. I also misattributed the number. The 2.20 tok/s in your description is the discrete Arc Pro B50. The 11.68 figure is the B390 iGPU from the follow-up comment, on a build that turned out to have neither Where the headroom actually is. The same comparison that clears the FWHT points somewhere much more interesting: PQ2_0 is 11.56 on Vulkan against 5.47 here, a 2.1x gap, while PTQ1_0 sits within 13%. Two backends and an unoptimized build both apply, so treat it as a direction rather than a measurement. But PTQ1_0 looks close to the achievable range on this hardware and PQ2_0 does not, which makes the PQ2_0 decode path the place to push. That is the same conclusion the follow-up comment reached, and it is better grounded than mine was. What stands from my review. The width cap is still real and still worth closing, just as an ordinary improvement rather than a blocker or a hidden explanation for your throughput. The request for Apologies for the noise. The finding was sound and the inference on top of it was not. |
|
Followed up on where the PQ2_0 headroom actually is, since that is the gap the measurements point at. I think it is in the unpack, and the comparison that makes it clearest is inside this PR rather than against another backend. Your two kernels are not at the same level of optimization, which matches the numbers: PTQ1_0 lands within about 13% of Vulkan while PQ2_0 sits at roughly half. The commit history says the same thing, since "vectorize PTQ1_0 dot product with SIMD trit unpacking and DP4A" is its own commit and the PQ2_0 kernel arrived after it without an equivalent pass. PTQ1_0, in this PR, loads 32 bits at a time: const uint32_t packed = get_int_from_uint8_aligned(bq->qs, g);
uint32_t v_lo = (packed & 0x000000FF) | ((packed & 0x0000FF00) << 8);PQ2_0, in this PR, loads one byte at a time: const uint8_t q = qs_chunk[j];
const uint32_t vi = ((q >> 0) & 0x3) | (((q >> 2) & 0x3) << 8) |
(((q >> 4) & 0x3) << 16) | (((q >> 6) & 0x3) << 24);Over one 32-element chunk that is eight scalar byte loads and eight shift-mask-or chains, about eleven integer ops each, to produce eight For reference, the CUDA kernel for the same format ( const int16_t * qs = (const int16_t *) bq2_0->qs + iqs * 4;
...
const int qe = __byte_perm(0x020100FF, 0x020100FF, q >> 0);
const int qo = __byte_perm(0x020100FF, 0x020100FF, q >> 2);
const int qx = __byte_perm(qe, qo, 0x5140);
const int qy = __byte_perm(qe, qo, 0x7362);Four 16-bit loads instead of eight 8-bit ones, and the 2-bit code to symbol mapping is a byte-permute lookup against the constant So the shape of the fix is the one you already applied to PTQ1_0: widen the load to a 32-bit word through Two caveats on this. I have not profiled anything and have no Intel GPU, so this is a hypothesis that fits the measurement rather than a measured attribution. And the cross-backend comparison behind it, Vulkan 11.56 against 5.47 here, came from a build that turned out to lack If you want a cheap check before committing to the rewrite: the ratio between your PTQ1_0 and PQ2_0 tg128 on the same box should narrow substantially if the unpack is the binding constraint, and barely move if it is not. |
|
I'll look into this and get back to you
…On Tue, Sep 22, 2026, 1:44 PM bri-prism ***@***.***> wrote:
*bri-prism* left a comment (PrismML-Eng/llama.cpp#235)
<#235 (comment)>
Followed up on where the PQ2_0 headroom actually is, since that is the gap
the measurements point at. I think it is in the unpack, and the comparison
that makes it clearest is inside this PR rather than against another
backend.
Your two kernels are not at the same level of optimization, which matches
the numbers: PTQ1_0 lands within about 13% of Vulkan while PQ2_0 sits at
roughly half. The commit history says the same thing, since "vectorize
PTQ1_0 dot product with SIMD trit unpacking and DP4A" is its own commit and
the PQ2_0 kernel arrived after it without an equivalent pass.
*PTQ1_0, in this PR, loads 32 bits at a time:*
const uint32_t packed = get_int_from_uint8_aligned(bq->qs, g);uint32_t v_lo = (packed & 0x000000FF) | ((packed & 0x0000FF00) << 8);
*PQ2_0, in this PR, loads one byte at a time:*
const uint8_t q = qs_chunk[j];const uint32_t vi = ((q >> 0) & 0x3) | (((q >> 2) & 0x3) << 8) |
(((q >> 4) & 0x3) << 16) | (((q >> 6) & 0x3) << 24);
Over one 32-element chunk that is eight scalar byte loads and eight
shift-mask-or chains, about eleven integer ops each, to produce eight dp4a
calls.
*For reference, the CUDA kernel for the same format* (
ggml-cuda/vecdotq.cuh) does the same eight dp4a calls from half the loads
and a fraction of the ALU:
const int16_t * qs = (const int16_t *) bq2_0->qs + iqs * 4;
...const int qe = __byte_perm(0x020100FF, 0x020100FF, q >> 0);const int qo = __byte_perm(0x020100FF, 0x020100FF, q >> 2);const int qx = __byte_perm(qe, qo, 0x5140);const int qy = __byte_perm(qe, qo, 0x7362);
Four 16-bit loads instead of eight 8-bit ones, and the 2-bit code to
symbol mapping is a byte-permute lookup against the constant 0x020100FF
rather than a shift and mask chain. Same VDR_PQ2_0_Q8_1_MMVQ = 1 and the
same per-chunk structure, so this is not a different algorithm, only a
different unpack.
So the shape of the fix is the one you already applied to PTQ1_0: widen
the load to a 32-bit word through get_int_from_uint8_aligned and unpack
four bytes at once, rather than looping over uint8_t. You will not have
__byte_perm on SYCL, but you already hand-rolled an equivalent permute
for PTQ1_0 in this same file, and the same widening trick applies directly,
since PQ2_0's 2-bit codes need less work to separate than base-3 trits do.
Two caveats on this. I have not profiled anything and have no Intel GPU,
so this is a hypothesis that fits the measurement rather than a measured
attribution. And the cross-backend comparison behind it, Vulkan 11.56
against 5.47 here, came from a build that turned out to lack /O2 and
NDEBUG, so the true gap may be smaller than 2.1x. The within-PR asymmetry
between your own two kernels does not depend on either caveat, which is why
I would trust that part more.
If you want a cheap check before committing to the rewrite: the ratio
between your PTQ1_0 and PQ2_0 tg128 on the same box should narrow
substantially if the unpack is the binding constraint, and barely move if
it is not.
—
Reply to this email directly, view it on GitHub
<#235?email_source=notifications&email_token=ACD25TZU3JVNC66XPKCIJS35QLQCVA5CNFSNUABFM5UWIORPF5TWS5BNNB2WEL2JONZXKZKDN5WW2ZLOOQXTKNZYGM4DKMRTHA42M4TFMFZW63VGMF2XI2DPOKSWK5TFNZ2KYZTPN52GK4S7MNWGSY3L#issuecomment-5783852389>,
or unsubscribe
<https://github.com/notifications/unsubscribe-auth/ACD25TZS7KFI3DDJ5O7SGPT5QLQCVAVCNFSNUABGKJSXA33TNF2G64TZHMYTCNRWHE3DKOJWGM5US43TOVSTWNJVGI3TAMJZGQ3DTILWAI>
.
You are receiving this because you authored the thread.Message ID:
***@***.***>
|
bri-prism
left a comment
There was a problem hiding this comment.
Reviewed at c5f6c97 against the CPU reference in ggml-quants.c, complementing the review above (which covers the Hadamard fallback and the Battlemage numbers) rather than repeating it.
Coverage first, because it changes what the green run means. The multi-column dispatchers (mul_mat_vec_ptq1_0_q8_1_sycl_switch_ncols, ..._pq2_0_..., ncols_dst 2..8) and the MUL_MAT_ID path have not been executed by any test on this PR as far as I can tell. can_use_mul_mat_vec_q claims every quantized type, so test-backend-ops -o MUL_MAT reaches tq2_0, hits the GGML_ABORT in mul_mat_vec_q_switch_type (mmvq.cpp:2710 on this branch, as the B390 log shows), and never gets to the Bonsai-shape cases that already exist in tests/test-backend-ops.cpp around line 9300: k = 1024/5120/6144/17408, rows 67/70, n = 1..8, plus a PTQ1_0 mul_mat_id case. The nine cases per type that did run are all single-column. Turning that abort into a return-false fallback would let the suite reach them; a run that includes those cases, or -o MUL_MAT_ID, is what I would want before approving.
The kernels themselves check out. The trit ordering in both the scalar ptq1_0_trit and the SIMD path in vec_dot_ptq1_0_q8_1 matches dequantize_row_ptq1_0 exactly, including the 16-wide / 8-wide / qh stage split and the q8 int index for every stage. The 16-bit lane widening is sound (255*3 cannot carry) and the perm assembly puts the four trits in the same byte order as the q8 ints they are dotted against. With QI_PTQ1_0 = 4 and VDR = 4 the mmvq template hands each lane one whole block with iqs = 0, so ignoring iqs is correct here, not an overcount. PQ2_0 matches dequantize_row_pq2_0 (code minus 1, so 11 maps to +2), and reading qs bytewise is right given the 34-byte block puts qs at a 2-byte offset. Dispatch is consistent: DMMV does not claim these types, MMQ is off on SYCL, and the CPY hunk correctly declares quant to F32 unsupported.
Small: the can_use_mul_mat_vec_q hunk is whitespace only. The description says it enables PTQ1_0 there, but ggml_is_quantized already covers it, so the hunk can be dropped.
Not blocking, on the PQ2_0 decode gap noted above: the per-byte unpack does four shift/mask/or steps per byte plus one dp4a. Loading qs as 16-bit pairs (always 2-byte aligned at stride 34) and unpacking two bytes per step would roughly halve the ALU work in that loop.
can_use_mul_mat_vec_q() claimed every quantized type via ggml_is_quantized(), so any type without a case in mul_mat_vec_q_switch_type() reached the GGML_ABORT in its default arm and killed the process. test-backend-ops -o MUL_MAT could not complete on SYCL for this reason: it died at mmvq.cpp:2710 on tq2_0, which also meant every case generated after that point never ran. Gate on an explicit list of the types the switch actually implements. The list matches the switch exactly, and is also identical to the set ggml_get_to_fp16_sycl() handles, so a type this declines cannot be served by the dequantize fallback either - hence the matching check in supports_op(), which lets the scheduler place those nodes on CPU rather than abort mid-graph. Arc Pro B50, oneAPI 2026.1, Level Zero: before: test -o MUL_MAT aborts, exit 134 after: 1098/1098 tests passed, exit 0 tq2_0 goes from "fatal error: unsupport data type" to "not supported".
The stock generator only emits 16 x 256 cases for these types, all of which take the single-column mmvq path, so the multi-column dispatchers (ncols_dst 2..8) had no coverage at all. Add the real Bonsai row lengths (k = 1024/5120/6144/17408) at odd row counts (m = 67/70, landing a partial row group) with n sweeping 1..8. Arc Pro B50, oneAPI 2026.1, Level Zero, per type: MUL_MAT 9 -> 102/102 passed MUL_MAT_ID 75/75 passed
|
Thanks for the detailed review, and for the two self-corrections — the retraction on the FWHT share of the frame saved me chasing it. One thing first, since it affects two of your points: you reviewed at The PQ2_0 unpack is already widenedYour suggestion was to drop the per-byte loop for a wider load. That is uint32_t val = (uint32_t) *(const uint16_t *) (bq2_0->qs + 2 * iqs);
const uint32_t vi = unpack_2bit_to_byte_lanes(val & 0xFFu);16-bit load, shared permute helper, same 2-byte alignment reasoning you gave. The commit also spreads a block over 16 lanes instead of 4 ( Which changes the headroom conclusion
No 2.1x gap — PQ2_0 is within 25% at n=1 and slightly faster at n=512. Your 5.47 t/s predates (n=2 first read 536 us; that was noise. Three re-runs: 151.6 / 152.7 / 155.8.) The abort class — fixedYou called it not a blocker; it was worth fixing anyway. Before: The suite now runs to CoverageAdded the Bonsai shapes (k = 1024/5120/6144/17408, m = 67/70, n = 1..8). Per type, on B50:
So the multi-column dispatchers and the MUL_MAT_ID path both execute now. Note Two correctionsThe You are right on the description. It is updated: both types are shown as fully dispatched with their own kernels and Still open
|
|
Data point: Intel Arc 140V (Lunar Lake laptop), Windows, oneAPI 2025.3, at head 450c5c3eb, with a whole-model logit check Daniel (dwymark) instructed a series of coding agents to reproduce this PR's measurements on his laptop, and worked with agents on further optimizations of the same kernels. Claude Code wrote this comment in Claude's voice on Daniel's instruction. The first section is the reproduction; the second section describes the optimization work. Reproduction Two commits are compared throughout: the base, c5f6c9718, which is the first native PQ2_0 kernel in this PR, and the head, 450c5c3eb. The only kernel change between them is de7a01b. Environment and method:
Values are median tokens per second. Repetitions agree within 6 percent.
The head roughly doubles PQ2_0 decode on Daniel's laptop relative to the base. PQ2_0 now decodes faster than PTQ1_0 on this GPU rather than at half of PTQ1_0's rate. For scale, one PQ2_0 token streams about 6.8 GB of weights, and a kernel that only reads memory sequentially reaches 108 GB/s on this GPU. That puts the single-stream decode ceiling on Daniel's laptop at about 16 tokens per second. The head sits a bit under half of that ceiling. The per-operator test in The PR description lists a full perplexity comparison against the CPU backend as not yet done. Claude ran a related check on full-vocabulary logits. The input was 512 WikiText prompt tokens followed by 32 decoded tokens, with prefill in FP32, and every eighth position was kept. Each kept position was scored against the CPU backend by normalized mean-square error (NMSE) and by mean KL divergence. PTQ1_0 logits are bit-identical between the base and the head, as expected since de7a01b does not touch PTQ1_0. For PQ2_0:
de7a01b changes the order in which the PQ2_0 zero point is applied, and that moves the FP32 logits slightly. This is not a correctness problem: both commits sit at about the same distance from the CPU backend, which is the reference that matters, and a gap of that size between backends is normal because the CPU backend quantizes activations differently. One consequence is worth stating. A logit dump from an earlier SYCL build will not match the head, so the CPU backend is the right reference for the unfinished check. Further optimization on Daniel's fork dwymark/llama.cpp#2 is based on the head. It adds opt-in explicit-SIMD decode kernels for PQ2_0 and for PTQ1_0, plus an FP16-input prefill path for both formats. Nothing changes by default; each path is enabled by an environment variable. On Daniel's laptop, PR 2 reaches 9.0 tokens per second on PQ2_0 decode and about 145 tokens per second on prompt processing. PTQ1_0 reaches 6.9 and 135. The FP32 output of PR 2 is bit-identical to the base's arithmetic for both formats. The speed suggests the decode path on Intel GPUs still has headroom. Daniel makes no assumption that PR 2 will be merged here, but you are welcome to merge it, adapt it, or copy from it verbatim without credit, whichever is useful. The logit probe and scorer used in the logit check above live on PR 2. Daniel is happy to rerun any of this at a later head, or to run the CPU-backend logit check for PTQ1_0 if that would help close the unfinished perplexity comparison. Written by Claude Code on Daniel's instruction. |
|
Tested head ❌ Crash introduced by this PR:
|
| op (ptq1_0 + pq2_0 cases) | result |
|---|---|
| MUL_MAT, incl. the new Bonsai-shape cases | 292/292 on 4 of 5 runs. One run had a single tolerance miss: pq2_0 m=16 n=1 k=256, ERR 0.000524 vs 0.0005. It didn't reproduce, so I read it as random-data noise. |
| MUL_MAT_ID | 166/166 |
| GET_ROWS | 8/8 |
Greedy 128-token generation (-ngl 99, temp 0) on the shipped v5 Ternary-Bonsai-2-27B PQ2_0 and PTQ1_0 is coherent on both formats.
Performance: new head vs previous head, same machine
llama-bench -ngl 99 -p 512 -n 128 -r 3, two interleaved rounds (round 1 / round 2, t/s):
| model | head | pp512 | tg128 |
|---|---|---|---|
| Bonsai 2 27B PQ2_0 | c5f6c97 | 81.65 / 74.79 | 5.08 / 5.05 |
| 450c5c3 | 83.45 / 76.89 | 10.07 / 9.24 | |
| Ternary-Bonsai 1.7B PQ2_0 | c5f6c97 | 1538 / 1541 | 71.8 / 71.9 |
| 450c5c3 | 1551 / 1549 | 133.1 / 131.0 | |
| Bonsai 2 27B PTQ1_0 | c5f6c97 | 85.24 / 74.62 | 10.40 / 9.94 |
| 450c5c3 | 91.45 / 75.29 | 11.06 / 9.95 | |
| 2B PTQ1_0 | c5f6c97 | 1207 / 1196 | 84.3 / 81.0 |
| 450c5c3 | 1195 / 1197 | 83.2 / 82.6 |
The ~2x PQ2_0 decode claim reproduces on Xe3 (27B: 5.08 → 10.07; 1.7B: 71.8 → 133). PTQ1_0 and prefill are unchanged, as expected; both heads share the same PTQ1_0 kernel. Round 2 runs a little slower for both heads on the 27B, which I read as thermal drift on the laptop.
With the CPY gate fixed, this looks good to me.
Tested with Claude Code.
Adding the two types to ggml_sycl_is_quantized_type() made supports_op accept f32/f16/bf16 -> PTQ1_0/PQ2_0 copies, but there is no quantize-rows kernel for either, so ggml_sycl_cpy() took the float -> quantized branch and died on GGML_ASSERT(ggml_sycl_can_quantize_rows_sycl(...)) at cpy.cpp:1298/1302/1308. Neither format can be produced by a row-at-a-time GPU kernel: the packing assumes the Hadamard rotation the offline converter applies, so the right answer is to decline the pair and let the scheduler place it elsewhere, not to add a kernel. Quantized -> same-quantized copies are unaffected and still take the memcpy path. Reported by @dwymark on an Arc B390 (Panther Lake Xe3); reproduced on an Arc Pro B50. test -b SYCL0 -o CPY -p "ptq1_0|pq2_0" before: abort at cpy.cpp:1298 after: 18/18 tests passed, float -> ternary reports "not supported" No regression: MUL_MAT 204/204, MUL_MAT_ID 150/150, GET_ROWS 8/8 for the two types. The unrelated tq2_0 -> tq2_0 abort at cpy.cpp:1455 and the SET_ROWS abort at set_rows.cpp:549 are both present on prism and untouched here.
|
Good catch, and thanks for the interleaved rounds - that is the part that makes the decode numbers believable. CPY crash: fixed in 42ce76dYour diagnosis was exactly right. Adding the two types to I took your second option rather than the first. Neither format can be produced by a row-at-a-time GPU kernel: the packing assumes the Hadamard rotation the offline converter applies, so a quantize-rows kernel would be wrong rather than merely missing. The fix declines the pair in if ((src1_type == GGML_TYPE_PTQ1_0 || src1_type == GGML_TYPE_PQ2_0) &&
src0_type != src1_type) {
return false;
}Keyed on the assert's own condition rather than added to the three per-source exclusion lists, so f16 and bf16 are covered by the same guard. Reproduced your abort on an Arc Pro B50 first, then confirmed the fix: float -> ternary now reports SET_ROWSAgreed, and thanks for checking it against prism rather than assuming. I hit the same abort at The one tolerance miss
NumbersThe 2x PQ2_0 decode reproducing on Xe3 (27B 5.08 -> 10.07, 1.7B 71.8 -> 133) is the third device now, after your earlier B390 run and my B50. PTQ1_0 and prefill flat is what I would expect since Fix is pushed. Happy to have you re-run whenever convenient. |
|
Short version of my earlier comment, since it landed mid-review and the headline got buried. On an Intel Arc 140V, dwymark/llama.cpp#2 on top of this PR takes PQ2_0 decode from 6.75 to 9.0 t/s (1.3x) and prompt processing from 51 to 145 t/s (2.8x). PTQ1_0 goes from 5.68 to 6.9 and from 48 to 135. FP32 output is bit-identical to c5f6c97 for both formats. The branch is now rebased onto 42ce76d and fast-forwards onto it. Two independent pieces: explicit-SIMD decode kernels for both formats, and an FP16-input GEMM for prefill. Both are off by default behind environment variables. Same stance as before: merge it, adapt it, or copy from it without credit, whichever is useful. On the still-open perplexity item: the earlier comment has PQ2_0 full-vocabulary logits scored against the CPU backend at both c5f6c97 and 450c5c3 (NMSE 1.4e-4, mean KL under 1e-4 for both), and PTQ1_0 logits are bit-identical between those two heads. Happy to run the PTQ1_0 comparison against CPU if that would close it. Written by Claude Code on Daniel's instruction. |
|
Re-tested at
Output: greedy 128-token generation on the shipped v5 Bonsai 2 27B is deterministic and unchanged by the fix. For PTQ1_0, 3 runs on Performance: the fix only touches
The PQ2_0 decode gain holds up under thermal throttling. pp512 is unchanged between heads. LGTM from the Xe3 side. One small note: the fix commit message credits the report to @dwymark on an Arc B390, but the B390 report was the review above from this account. Not important, just mentioning it in case the history matters to you. Tested with Claude Code. |
|
Correction to the performance section of my previous comment. I attributed the ~20% lower absolute numbers to thermal throttling. That was wrong: a CPU benchmark loop I believed I had stopped was still running on this UMA laptop, competing for memory bandwidth. Everything else in that comment stands: the CPY fix verification, the op results and the deterministic output. Clean rerun at
This matches my first review (27B PQ2_0 5.08 → 10.07 t/s, 1.7B 71.8 → 133 t/s). The LGTM stands. Tested with Claude Code. |
|
I also implemented the PQ2_0 kernel in my local repo (not branched from this PR) and can perform better in PP512. Let me try later if my approach can benefit this backend kernel to shorten prefill time further. Model: Bonsai 2 27B PQ2_0
|
Summary
This PR implements native Intel SYCL / oneAPI support for PTQ1_0 (base-3 trit packed ternary) and PQ2_0 (group-128 2-bit quantization), enabling full GPU offload and execution of ternary models like
Ternary-Bonsai-2-27B-PTQ1_0on Intel Arc GPUs.Before this PR the SYCL backend had no reference to either format, so these models aborted during load at
convert.cppand could not run on SYCL at all.Changes
1. Dequantization (
dequantize.hpp,convert.cpp)ptq1_0_trit()base-3 recurrence unpacking anddequantize_ptq1_0()/dequantize_pq2_0()DPC++ kernels.PTQ1_0andPQ2_0conversion paths inggml_get_to_fp32_sycl,ggml_get_to_fp16_sycl, andggml_get_to_fp16_nc_sycl.2. MMVQ vector dot kernels (
vecdotq.hpp,mmvq.cpp) — both formats get a native kernel and full dispatch:vec_dot_ptq1_0_q8_1,VDR_PTQ1_0_Q8_1_MMVQ = 4: evaluates the 128-weight ternary block directly in registers across sub-group lanes, no intermediate FP32 VRAM. Trits are unpacked SIMD-style from 32-bit loads and reduced with DP4A.vec_dot_pq2_0_q8_1,VDR_PQ2_0_Q8_1_MMVQ = 1withMMVQ_PQ2_0_QI = 16: 16-bit loads through a sharedunpack_2bit_to_byte_lanes()permute helper. The dedicated QI spreads each block over 16 lanes rather than the 4 thatQI_PQ2_0would imply, avoiding an 8-deep serial DP4A chain per lane — measured ~2.1x on the mat-vec.switch_ncols, up to 8) dispatch for both types.3. Type gating (
ggml-sycl.cpp)can_use_mul_mat_vec_q()now gates on the set of types MMVQ actually implements instead ofggml_is_quantized(). Previously the backend claimed every quantized type andmul_mat_vec_q_switch_type()aborted the process on any type without a case — which is whytest-backend-ops -o MUL_MATcould not complete on SYCL. Unsupported types now fall back cleanly to CPU. Matching check insupports_op().4. Operators (
getrows.cpp,cpy.cpp)PTQ1_0/PQ2_0support inget_rows(embedding gather) andcpy.Verification
All numbers on Intel Arc Pro B50 (Battlemage, 16 GB), oneAPI 2026.1 / Level Zero, Release.
Correctness —
test-backend-opscompares each op against the CPU reference numerically:MUL_MATMUL_MAT_IDCoverage includes real Bonsai weight shapes (k = 1024/5120/6144/17408) at odd row counts (m = 67/70) with n = 1..8, added in this PR — the stock generator only emits 16x256 single-column cases, so the multi-column dispatchers had no coverage.
Full
-o MUL_MATsweep: 1098/1098 passed. Before the gating fix this run aborted atmmvq.cpp:2710.Performance —
test-backend-ops perf -o MUL_MAT, m=4096 k=14336:End-to-end —
Ternary-Bonsai-2-27B-PTQ1_0.gguf, all 64 layers onSYCL0: 2.20 tok/s, vs 0.89 naive dequant and 0.39 CPU.Notes and limitations
llama-simple. Per-op numerical agreement is covered bytest-backend-opsabove; a full perplexity comparison against CPU is not yet done.ggml_sycl_op_fwhtsupports widths up to 512, so models withhadamard.block_size = 1024take the dense mat-mul fallback. Numerically correct, and measured at a negligible share of a decode frame at batch 1, but the wide-kernel gap versus CUDA/Metal/Vulkan remains open and is out of scope here.test-backend-opssuite still stops atset_rows.cpp:549, a pre-existing abort in an unrelated op.