HelloSun commited on
Commit
111541a
·
1 Parent(s): 722130e

Pipeline stage P2: explain and fix refiner Q8_0 slowdown, V-pack hoist + 128x128 flash-attn tiles

Browse files

- root cause: the reference Q4_K GGUF stores the 2 noise + 2 context refiner
blocks as Q8_0; on a memory-bound CPU GEMM the same M=1024 w13 matmul is
89.4 ms (Q8_0) vs 42.9 ms (Q4_K), ~-1.5 s per 8-step run
- tools/requant_refiner.py + tools/q4k.c rewrite only the 16 refiner
attention/FFN matrices as Q4_K (adaLN kept at Q8_0); build.sh now produces
z_image_turbo-Q4_K-ffn13-rq.gguf (opt-in, pixel diff mean 6.4)
- flash-attn: hoist V packing to once per head and raise tiles to 128x128;
FLASH_ATTN_EXT 4663 -> 4451 ms (-4.5%), pixel-identical
- add stages/stage_p2_refiner notes, SmartTest rq row, README findings

README.md CHANGED
@@ -26,10 +26,13 @@ stable-diffusion.cpp 在 **純 CPU** 上跑 Z-Image-Turbo 的優化實驗。這
26
  - 單獨啟用 AMX 權重佈局(`SD_CPU_EXTRA_BUFT=1`,Q4_K)相對 `amx_lto` 約快 15–20%,是 sd.cpp 內最主要的收益來源。
27
  - AVX512 相對 AVX2 約快 2 倍(118.5 s → 58.1 s)。
28
  - FFN gate/up 融合(w1 + w3 → w13)輸出與原版逐位元相同,但在這台機器上沒有可量測的加速。
 
 
29
  - Q4_K 是 sd.cpp 在此 CPU 上最快的量化格式。Q8_0 即使開 AMX 也比 Q4_K 慢。
30
  - 峰值 RSS:sd.cpp 7.7–11.1 GiB,OpenVINO 12.6 GiB,皆遠低於記憶體守衛上限。
31
 
32
  **雜訊說明**:同一 stock 設定在不同批次的總生成時間為 56.8 s(前次)與 59.7 s(本次)。排名相近的列(例如 Q4_K-ffn13+AMXw 與 Q4_K+AMXw)差距在 ±5% 內,不宜過度解讀。
 
33
 
34
  ## 比較表(中位數,n = 2)
35
 
@@ -68,6 +71,13 @@ OpenVINO 的 VAE 解碼約 0.55 s,sd.cpp 約 10 s。第一階段嘗試了 conv
68
  llamafile 版本把 VAE 解碼從 10.7 s 降到 6.6 s(-38%),加上 conv-direct 後為 5.0 s(-53%)。
69
  詳細數據與重現步驟見 `stages/stage1_vae/README.md`。
70
 
 
 
 
 
 
 
 
71
  ## 各版本的意義
72
 
73
  | variant | CMake 設定 | 說明 |
@@ -84,6 +94,7 @@ llamafile 版本把 VAE 解碼從 10.7 s 降到 6.6 s(-38%),加上 conv-di
84
 
85
  - `SD_CPU_EXTRA_BUFT=1`:擴散模型的 2 維權重改用 CPU 的 AMX 佈局(實驗性質,預設關閉)。
86
  - `z_image_turbo-Q4_K-ffn13.gguf`:由 `tools/fuse_ffn.py` 把 FFN 的 w1、w3 串接成 w13,模型會自動偵測並使用融合路徑。
 
87
  - `GGML_CPU_PROFILE=1`:逐算子計時,輸出到 stderr(僅供分析,預設關閉)。
88
 
89
  ## Profile(Q4_K,優化前)
@@ -133,7 +144,11 @@ REPEAT=2 ./SmartTest.sh # 每列跑 2 次取中位數
133
  - `patches/`
134
  - `sdcpp-0001-zimage-ffn-w13-and-cpu-extra-buft.patch`:`z_image.hpp`(FFN w13 融合)、`model_manager.cpp`(AMX 權重佈局)
135
  - `ggml-0001-cpu-op-profile.patch`:`ggml-cpu.c`(逐算子計時)
 
 
136
  - `tools/fuse_ffn.py`:把 GGUF 的 FFN w1、w3 串接成 w13(Q4_K/Q8_0 的位元組串接是合法的)。
 
 
137
  - `tools/measure_rss.py`:執行指令並回報行程樹的峰值 RSS 與 wall time。
138
  - `results/`:`results.csv`、`results.md`、`hardware.txt`、`images/`(每次執行的 512x512 輸出)、`logs/`。
139
 
 
26
  - 單獨啟用 AMX 權重佈局(`SD_CPU_EXTRA_BUFT=1`,Q4_K)相對 `amx_lto` 約快 15–20%,是 sd.cpp 內最主要的收益來源。
27
  - AVX512 相對 AVX2 約快 2 倍(118.5 s → 58.1 s)。
28
  - FFN gate/up 融合(w1 + w3 → w13)輸出與原版逐位元相同,但在這台機器上沒有可量測的加速。
29
+ - **Refiner 慢 2 倍的根因**:參考 Q4_K GGUF 把 2 個 noise-refiner 與 2 個 context-refiner 存成 **Q8_0**(主層是 Q4_K)。在記憶體受限的 CPU GEMM 上,同形狀 w13 每次 89.4 ms(Q8_0)→ 42.9 ms(Q4_K)。用 `tools/requant_refiner.py` 只把這 16 個 refiner 的 attention/FFN 權重改成 Q4_K,每 8 步約省 **1.5 s**(`-rq` 模型,屬 opt-in,像素差 mean 6.4)。
30
+ - **flash-attn tiling**:tile 由 64x64 改為 128x128 並把 V 打包提升為每頭一次,`FLASH_ATTN_EXT` 4663 → 4451 ms(-4.5%),輸出逐像素相同。
31
  - Q4_K 是 sd.cpp 在此 CPU 上最快的量化格式。Q8_0 即使開 AMX 也比 Q4_K 慢。
32
  - 峰值 RSS:sd.cpp 7.7–11.1 GiB,OpenVINO 12.6 GiB,皆遠低於記憶體守衛上限。
33
 
34
  **雜訊說明**:同一 stock 設定在不同批次的總生成時間為 56.8 s(前次)與 59.7 s(本次)。排名相近的列(例如 Q4_K-ffn13+AMXw 與 Q4_K+AMXw)差距在 ±5% 內,不宜過度解讀。
35
+ **負載說明**:主機是共用環境,co-tenant 負載會讓**所有**絕對時間一起上下移動(本 session 觀察到 OpenVINO 從 37.5 s 到 25 s、sd.cpp 從 46 s 到 36 s)。因此跨批次只能比**比值**或在同一批交錯量測;表中的絕對秒數是記錄當時負載下的結果,不是跨批次可比的定值。
36
 
37
  ## 比較表(中位數,n = 2)
38
 
 
71
  llamafile 版本把 VAE 解碼從 10.7 s 降到 6.6 s(-38%),加上 conv-direct 後為 5.0 s(-53%)。
72
  詳細數據與重現步驟見 `stages/stage1_vae/README.md`。
73
 
74
+ ## Pipeline 階段 P2:refiner 重新量化 + flash-attn tile(已完成)
75
+
76
+ 參考 GGUF 的 refiner 區塊是 Q8_0,是 P1「refiner w13 慢 1.7x」未解之謎的根因。`tools/requant_refiner.py`
77
+ 只改 16 個 refiner attention/FFN 權重為 Q4_K(adaLN 保持 Q8_0),同形狀每次 89.4 → 42.9 ms,每 8 步省約 1.5 s。
78
+ flash-attn 的 V 打包提升為每頭一次、tile 改 128x128,`FLASH_ATTN_EXT` 再降 4.5%,且輸出逐像素相同。
79
+ 詳見 `stages/stage_p2_refiner/README.md`。
80
+
81
  ## 各版本的意義
82
 
83
  | variant | CMake 設定 | 說明 |
 
94
 
95
  - `SD_CPU_EXTRA_BUFT=1`:擴散模型的 2 維權重改用 CPU 的 AMX 佈局(實驗性質,預設關閉)。
96
  - `z_image_turbo-Q4_K-ffn13.gguf`:由 `tools/fuse_ffn.py` 把 FFN 的 w1、w3 串接成 w13,模型會自動偵測並使用融合路徑。
97
+ - `z_image_turbo-Q4_K-ffn13-rq.gguf`:再由 `tools/requant_refiner.py` 把 refiner 的 16 個 attention/FFN 權重從 Q8_0 改成 Q4_K(速度優先、opt-in,輸出與 base 略有差異)。
98
  - `GGML_CPU_PROFILE=1`:逐算子計時,輸出到 stderr(僅供分析,預設關閉)。
99
 
100
  ## Profile(Q4_K,優化前)
 
144
  - `patches/`
145
  - `sdcpp-0001-zimage-ffn-w13-and-cpu-extra-buft.patch`:`z_image.hpp`(FFN w13 融合)、`model_manager.cpp`(AMX 權重佈局)
146
  - `ggml-0001-cpu-op-profile.patch`:`ggml-cpu.c`(逐算子計時)
147
+ - `ggml-0002-flash-attn-kpack.patch`:`ops.cpp`(flash-attn K/V 打包提升為每頭一次)
148
+ - `ggml-0003-flash-attn-tiles.patch`:`common.h`(flash-attn tile 128x128)
149
  - `tools/fuse_ffn.py`:把 GGUF 的 FFN w1、w3 串接成 w13(Q4_K/Q8_0 的位元組串接是合法的)。
150
+ - `tools/requant_refiner.py` + `tools/q4k.c`:把 refiner 區塊的 Q8_0 權重重新量化成 Q4_K。
151
+ - `stages/stage_p2_refiner/`:refiner 根因分析、requant 與 flash-attn tile 的數據。
152
  - `tools/measure_rss.py`:執行指令並回報行程樹的峰值 RSS 與 wall time。
153
  - `results/`:`results.csv`、`results.md`、`hardware.txt`、`images/`(每次執行的 512x512 輸出)、`logs/`。
154
 
SmartTest.sh CHANGED
@@ -48,12 +48,15 @@ MATRIX=(
48
  # stage 1 (VAE decode): llamafile sgemm build, with and without conv-direct for the VAE
49
  "llamafile-Q4_K-ffn13+AMXw|llamafile|Q4_K-ffn13|SD_CPU_EXTRA_BUFT=1|"
50
  "llamafile-Q4_K-ffn13+AMXw+convdirect|llamafile|Q4_K-ffn13|SD_CPU_EXTRA_BUFT=1|--conv-direct auto,vae=on"
 
 
51
  )
52
 
53
  model_file() {
54
  case "$1" in
55
  Q4_K) echo "$MODELS/z_image_turbo-Q4_K.gguf" ;;
56
  Q4_K-ffn13) echo "$MODELS/z_image_turbo-Q4_K-ffn13.gguf" ;;
 
57
  Q4_0) echo "$MODELS/z_image_turbo-Q4_0.gguf" ;;
58
  Q8_0) echo "$MODELS/z_image_turbo-Q8_0.gguf" ;;
59
  *) return 1 ;;
 
48
  # stage 1 (VAE decode): llamafile sgemm build, with and without conv-direct for the VAE
49
  "llamafile-Q4_K-ffn13+AMXw|llamafile|Q4_K-ffn13|SD_CPU_EXTRA_BUFT=1|"
50
  "llamafile-Q4_K-ffn13+AMXw+convdirect|llamafile|Q4_K-ffn13|SD_CPU_EXTRA_BUFT=1|--conv-direct auto,vae=on"
51
+ # stage P2: refiner weights requantized Q8_0 -> Q4_K (speed-first, opt-in)
52
+ "llamafile-Q4_K-ffn13-rq+AMXw+convdirect|llamafile|Q4_K-ffn13-rq|SD_CPU_EXTRA_BUFT=1|--conv-direct auto,vae=on"
53
  )
54
 
55
  model_file() {
56
  case "$1" in
57
  Q4_K) echo "$MODELS/z_image_turbo-Q4_K.gguf" ;;
58
  Q4_K-ffn13) echo "$MODELS/z_image_turbo-Q4_K-ffn13.gguf" ;;
59
+ Q4_K-ffn13-rq) echo "$MODELS/z_image_turbo-Q4_K-ffn13-rq.gguf" ;;
60
  Q4_0) echo "$MODELS/z_image_turbo-Q4_0.gguf" ;;
61
  Q8_0) echo "$MODELS/z_image_turbo-Q8_0.gguf" ;;
62
  *) return 1 ;;
build.sh CHANGED
@@ -140,6 +140,7 @@ if [[ ${#SELECTED[@]} -gt 0 ]]; then
140
  apply_patch "$SRC" "$ROOT/patches/sdcpp-0001-zimage-ffn-w13-and-cpu-extra-buft.patch"
141
  apply_patch "$SRC/ggml" "$ROOT/patches/ggml-0001-cpu-op-profile.patch"
142
  apply_patch "$SRC/ggml" "$ROOT/patches/ggml-0002-flash-attn-kpack.patch"
 
143
 
144
  # ------------------------------------------------------------ build
145
  for v in "${SELECTED[@]}"; do
@@ -154,6 +155,17 @@ if [[ ${#SELECTED[@]} -gt 0 ]]; then
154
  cp "$build_dir/bin/sd-cli" "$BIN/$v/sd-cli"
155
  log "-> $BIN/$v/sd-cli"
156
  done
 
 
 
 
 
 
 
 
 
 
 
157
  fi
158
 
159
  # ---------------------------------------------------------------- models
@@ -200,6 +212,14 @@ PY
200
  log "fusing FFN w1/w3 -> w13 (Q4_K)"
201
  "$PY" "$ROOT/tools/fuse_ffn.py" "$MODELS/z_image_turbo-Q4_K.gguf" "$MODELS/z_image_turbo-Q4_K-ffn13.gguf"
202
  fi
 
 
 
 
 
 
 
 
203
  fi
204
 
205
  log "done"
 
140
  apply_patch "$SRC" "$ROOT/patches/sdcpp-0001-zimage-ffn-w13-and-cpu-extra-buft.patch"
141
  apply_patch "$SRC/ggml" "$ROOT/patches/ggml-0001-cpu-op-profile.patch"
142
  apply_patch "$SRC/ggml" "$ROOT/patches/ggml-0002-flash-attn-kpack.patch"
143
+ apply_patch "$SRC/ggml" "$ROOT/patches/ggml-0003-flash-attn-tiles.patch"
144
 
145
  # ------------------------------------------------------------ build
146
  for v in "${SELECTED[@]}"; do
 
155
  cp "$build_dir/bin/sd-cli" "$BIN/$v/sd-cli"
156
  log "-> $BIN/$v/sd-cli"
157
  done
158
+
159
+ # q4k helper: wraps ggml's own Q4_K quantizer for tools/requant_refiner.py
160
+ ref_build="$SRC/build-llamafile"
161
+ [[ -f "$ref_build/ggml/src/libggml-base.a" ]] || ref_build="$SRC/build-${SELECTED[0]}"
162
+ if [[ -f "$ref_build/ggml/src/libggml-base.a" ]]; then
163
+ log "building q4k quantizer helper"
164
+ gcc -O2 -fopenmp -I "$SRC/include" -I "$SRC/ggml/include" -I "$SRC/ggml/src" \
165
+ "$ROOT/tools/q4k.c" "$ref_build/ggml/src/libggml-base.a" \
166
+ -o "$WORK/q4k" -lpthread -lm -lstdc++ \
167
+ || die "failed to build q4k helper"
168
+ fi
169
  fi
170
 
171
  # ---------------------------------------------------------------- models
 
212
  log "fusing FFN w1/w3 -> w13 (Q4_K)"
213
  "$PY" "$ROOT/tools/fuse_ffn.py" "$MODELS/z_image_turbo-Q4_K.gguf" "$MODELS/z_image_turbo-Q4_K-ffn13.gguf"
214
  fi
215
+ # refiner requant: the reference GGUF stores the 2 noise + 2 context refiner
216
+ # blocks as Q8_0, which is ~2x slower than Q4_K on a memory-bound CPU GEMM.
217
+ # Rewrite just those 16 attention/FFN matrices as Q4_K (adaLN left at Q8_0).
218
+ if [[ -x "$WORK/q4k" && ! -f "$MODELS/z_image_turbo-Q4_K-ffn13-rq.gguf" ]]; then
219
+ log "requantizing refiner blocks Q8_0 -> Q4_K"
220
+ Q4K="$WORK/q4k" "$PY" "$ROOT/tools/requant_refiner.py" \
221
+ "$MODELS/z_image_turbo-Q4_K-ffn13.gguf" "$MODELS/z_image_turbo-Q4_K-ffn13-rq.gguf"
222
+ fi
223
  fi
224
 
225
  log "done"
patches/ggml-0002-flash-attn-kpack.patch CHANGED
@@ -1,8 +1,8 @@
1
  diff --git a/src/ggml-cpu/ops.cpp b/src/ggml-cpu/ops.cpp
2
- index e8ef14f2..8a155984 100644
3
  --- a/src/ggml-cpu/ops.cpp
4
  +++ b/src/ggml-cpu/ops.cpp
5
- @@ -9238,10 +9238,20 @@ static void ggml_compute_forward_flash_attn_ext_f16_one_chunk(
6
  }
7
  }
8
 
@@ -14,16 +14,23 @@ index e8ef14f2..8a155984 100644
14
  +static thread_local std::vector<float> fa_kpack_buf;
15
  +static thread_local fa_kpack_key fa_kpack_cur;
16
  +static thread_local bool fa_kpack_valid = false;
 
 
 
 
 
 
17
  +
18
  static void ggml_compute_forward_flash_attn_ext_tiled(
19
  const ggml_compute_params * params,
20
  ggml_tensor * dst,
21
  int ir0, int ir1) {
22
  + fa_kpack_valid = false; // new chunk: K contents may differ from the previous call
 
23
  const ggml_tensor * q = dst->src[0];
24
  const ggml_tensor * k = dst->src[1];
25
  const ggml_tensor * v = dst->src[2];
26
- @@ -9381,6 +9391,43 @@ static void ggml_compute_forward_flash_attn_ext_tiled(
27
  memset(K_f32, 0, DK * KV_TILE_SZ * sizeof(float));
28
  memset(V32, 0, KV_TILE_SZ * DV * sizeof(float));
29
 
@@ -63,11 +70,37 @@ index e8ef14f2..8a155984 100644
63
  + }
64
  + kpack = fa_kpack_buf.data();
65
  + }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
66
  +
67
  for (int64_t ic = 0; ic < nek1; ic += KV_TILE_SZ) {
68
  const int kv_tile = (int)std::min((int64_t)KV_TILE_SZ, nek1 - ic);
69
 
70
- @@ -9406,24 +9453,10 @@ static void ggml_compute_forward_flash_attn_ext_tiled(
71
  }
72
  }
73
 
@@ -95,3 +128,28 @@ index e8ef14f2..8a155984 100644
95
  ggml_vec_scale_f32(Q_TILE_SZ * KV_TILE_SZ, KQ, scale);
96
 
97
  if (logit_softcap != 0.0f) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  diff --git a/src/ggml-cpu/ops.cpp b/src/ggml-cpu/ops.cpp
2
+ index e8ef14f2..bc6918ca 100644
3
  --- a/src/ggml-cpu/ops.cpp
4
  +++ b/src/ggml-cpu/ops.cpp
5
+ @@ -9238,10 +9238,27 @@ static void ggml_compute_forward_flash_attn_ext_f16_one_chunk(
6
  }
7
  }
8
 
 
14
  +static thread_local std::vector<float> fa_kpack_buf;
15
  +static thread_local fa_kpack_key fa_kpack_cur;
16
  +static thread_local bool fa_kpack_valid = false;
17
+ +// Stage P1b: same idea for V - pack [nek1][DV] F32 for the current head once instead of
18
+ +// re-converting the whole V for every Q tile of that head.
19
+ +struct fa_vpack_key { const void * data; int64_t iv2, iv3, nek1, nbv1, nbv2, nbv3, dv; };
20
+ +static thread_local std::vector<float> fa_vpack_buf;
21
+ +static thread_local fa_vpack_key fa_vpack_cur;
22
+ +static thread_local bool fa_vpack_valid = false;
23
  +
24
  static void ggml_compute_forward_flash_attn_ext_tiled(
25
  const ggml_compute_params * params,
26
  ggml_tensor * dst,
27
  int ir0, int ir1) {
28
  + fa_kpack_valid = false; // new chunk: K contents may differ from the previous call
29
+ + fa_vpack_valid = false;
30
  const ggml_tensor * q = dst->src[0];
31
  const ggml_tensor * k = dst->src[1];
32
  const ggml_tensor * v = dst->src[2];
33
+ @@ -9381,6 +9398,69 @@ static void ggml_compute_forward_flash_attn_ext_tiled(
34
  memset(K_f32, 0, DK * KV_TILE_SZ * sizeof(float));
35
  memset(V32, 0, KV_TILE_SZ * DV * sizeof(float));
36
 
 
70
  + }
71
  + kpack = fa_kpack_buf.data();
72
  + }
73
+ +
74
+ + // packed V for this head: [n_kv_tiles*KV_TILE_SZ][DV], zero-padded last tile
75
+ + const float * vpack = nullptr;
76
+ + {
77
+ + const fa_vpack_key key{ v->data, iv2, iv3, nek1, nbv1, nbv2, nbv3, DV };
78
+ + const int64_t n_kv_tiles = (nek1 + KV_TILE_SZ - 1) / KV_TILE_SZ;
79
+ + const size_t need = (size_t) (n_kv_tiles * KV_TILE_SZ * DV);
80
+ + if (!fa_vpack_valid || memcmp(&key, &fa_vpack_cur, sizeof(key)) != 0 || fa_vpack_buf.size() < need) {
81
+ + if (fa_vpack_buf.size() < need) {
82
+ + fa_vpack_buf.resize(need);
83
+ + }
84
+ + float * dst_pack = fa_vpack_buf.data();
85
+ + memset(dst_pack, 0, need * sizeof(float));
86
+ + for (int64_t tk = 0; tk < nek1; tk++) {
87
+ + const char * v_data = (const char *)v->data + tk*nbv1 + iv2*nbv2 + iv3*nbv3;
88
+ + if (kv_type == GGML_TYPE_F16) {
89
+ + ggml_cpu_fp16_to_fp32((const ggml_fp16_t *)v_data, dst_pack + tk * DV, DV);
90
+ + } else {
91
+ + memcpy(dst_pack + tk * DV, v_data, DV * sizeof(float));
92
+ + }
93
+ + }
94
+ + fa_vpack_cur = key;
95
+ + fa_vpack_valid = true;
96
+ + }
97
+ + vpack = fa_vpack_buf.data();
98
+ + }
99
  +
100
  for (int64_t ic = 0; ic < nek1; ic += KV_TILE_SZ) {
101
  const int kv_tile = (int)std::min((int64_t)KV_TILE_SZ, nek1 - ic);
102
 
103
+ @@ -9406,24 +9486,10 @@ static void ggml_compute_forward_flash_attn_ext_tiled(
104
  }
105
  }
106
 
 
128
  ggml_vec_scale_f32(Q_TILE_SZ * KV_TILE_SZ, KQ, scale);
129
 
130
  if (logit_softcap != 0.0f) {
131
+ @@ -9472,21 +9538,14 @@ static void ggml_compute_forward_flash_attn_ext_tiled(
132
+ }
133
+
134
+ // V accumulation: VKQ32 += softmax(KQ) * V
135
+ - // Pack V tile to contiguous F32, zero-padded
136
+ - for (int tk = 0; tk < kv_tile; tk++) {
137
+ - const char * v_data = (const char *)v->data + (ic + tk)*nbv1 + iv2*nbv2 + iv3*nbv3;
138
+ - if (kv_type == GGML_TYPE_F16) {
139
+ - ggml_cpu_fp16_to_fp32((const ggml_fp16_t *)v_data, V32 + tk * DV, DV);
140
+ - } else {
141
+ - memcpy(V32 + tk * DV, v_data, DV * sizeof(float));
142
+ - }
143
+ - }
144
+ + // V tile comes from the per-head packed buffer (see above)
145
+ + const float * V_tile = vpack + ic * DV;
146
+ for (int tq = 0; tq < Q_TILE_SZ; tq++) {
147
+ if (skip[tq]) {
148
+ memset(KQ + tq * KV_TILE_SZ, 0, KV_TILE_SZ * sizeof(float));
149
+ }
150
+ }
151
+ - simd_gemm(VKQ32, KQ, V32, Q_TILE_SZ, KV_TILE_SZ, DV);
152
+ + simd_gemm(VKQ32, KQ, V_tile, Q_TILE_SZ, KV_TILE_SZ, DV);
153
+ }
154
+
155
+ // sinks (apply only to valid rows in the tile)
patches/ggml-0003-flash-attn-tiles.patch ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ diff --git a/src/ggml-cpu/common.h b/src/ggml-cpu/common.h
2
+ index abbadc35..55ed9826 100644
3
+ --- a/src/ggml-cpu/common.h
4
+ +++ b/src/ggml-cpu/common.h
5
+ @@ -6,8 +6,8 @@
6
+ #include "ggml-impl.h"
7
+ #include "simd-mappings.h"
8
+
9
+ -#define GGML_FA_TILE_Q 64
10
+ -#define GGML_FA_TILE_KV 64
11
+ +#define GGML_FA_TILE_Q 128
12
+ +#define GGML_FA_TILE_KV 128
13
+
14
+ #ifdef __cplusplus
15
+
stages/stage_p2_refiner/README.md ADDED
@@ -0,0 +1,109 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Stage P2 — noise-refiner slowdown, refiner requant, flash-attn tiling
2
+
3
+ Continues stage P1. Three things:
4
+
5
+ 1. Explains the stage-P1 open question: *"noise-refiner w13 is ~1.7x slower per call than the
6
+ main-layer w13 at the same shape; not explained yet."*
7
+ 2. Fixes it with a weight-only requant (`tools/requant_refiner.py`).
8
+ 3. Tunes the flash-attn tiled kernel tile sizes and hoists the V packing.
9
+
10
+ All numbers below are from the same `llamafile` build, `SD_CPU_EXTRA_BUFT=1`,
11
+ `--conv-direct auto,vae=on`, Q4_K-ffn13, 512x512, 8 steps, seed 42.
12
+
13
+ ## 1. Root cause of the refiner w13 "2x" anomaly
14
+
15
+ The noise-refiner and context-refiner blocks in the reference `leejet/Z-Image-Turbo-GGUF`
16
+ Q4_K file are **Q8_0**, while every main-layer transformer block is **Q4_K**
17
+ (`ggml` type `8` = `Q8_0`, `12` = `Q4_K`). On a memory-bound CPU GEMM the 8-bit
18
+ weights move twice the bytes, so the refiner matmuls run at roughly half the
19
+ throughput of the Q4_K main layers even though the refiner token count is smaller
20
+ (M=1024 vs M=1088).
21
+
22
+ Shape-level profile (`GGML_CPU_PROFILE=2`), same M=1024 `w13` matmul (K=3840, N=20480):
23
+
24
+ | refiner w13 weights | per call |
25
+ |---|---|
26
+ | Q8_0 (reference GGUF) | 89.4 ms |
27
+ | Q4_K (after requant) | 42.9 ms |
28
+
29
+ Per 8-step run the four refiner blocks (2 noise + 2 context) went from ~2985 ms to
30
+ ~1410 ms, i.e. about **-1.5 s** of sampling.
31
+
32
+ ## 2. Requant tool (`tools/requant_refiner.py`)
33
+
34
+ Rewrites only the 16 refiner **attention** (`qkv`, `out`) and **feed-forward**
35
+ (`w13`, `w2`) Q8_0 tensors as Q4_K. Everything else — including the small
36
+ `adaLN_modulation` tensors, whose M=1 GEMV cost is irrelevant — is copied
37
+ byte-for-byte. Uses ggml's own Q4_K quantizer through the tiny `tools/q4k.c`
38
+ helper, so the requantized values match what ggml produces natively.
39
+
40
+ Output: `z_image_turbo-Q4_K-ffn13-rq.gguf` (built automatically by `build.sh`).
41
+
42
+ Quality trade-off (8 steps, seed 42, vs the un-requantized model):
43
+
44
+ | model | pixel diff vs base (mean / max) |
45
+ |---|---|
46
+ | base Q4_K-ffn13 (refiner Q8_0) | ref |
47
+ | **Q4_K-ffn13-rq** (refiner Q4_K) | 6.40 / 252 |
48
+
49
+ Small but visible, so it is an **opt-in** speed variant; keep using the base model
50
+ when refiner fidelity matters.
51
+
52
+ ## 3. Flash-attn tile sizes + V-pack hoist
53
+
54
+ Stage P1 hoisted the K packing to once per head. This stage does the same for V
55
+ (previously re-converted for every Q tile), zero-padding the tail tile, and raises
56
+ the tile size from 64x64 to 128x128 (`patches/ggml-0003-flash-attn-tiles.patch`).
57
+
58
+ | build | FLASH_ATTN_EXT (8 steps) |
59
+ |---|---|
60
+ | P1 (K hoist, 64x64) | 4663 ms |
61
+ | + V hoist, 64x64 | 4671 ms (no change: V repack was already cheap) |
62
+ | + V hoist, 128x128 | 4451 ms (-4.5%) |
63
+ | + V hoist, 128x256 | 4427 ms (~-5%, more scratch; not used) |
64
+
65
+ Output is **pixel-identical** to the P1 build (diff mean 0, max 0) at every tile
66
+ size tested, so the tiling change is safe. The V hoist alone shows the "measure,
67
+ don't assume" point: it is correct but buys nothing, because the F32 tile copy was
68
+ never the bottleneck — the `simd_gemm` F32 math is.
69
+
70
+ ## 4. Where the time still goes (and the remaining gap to OpenVINO)
71
+
72
+ On a lightly loaded host, OpenVINO INT4 finishes the whole 8-step 512x512 job in
73
+ ~25-27 s (VAE decode ~0.3 s). sd.cpp (base or rq) lands around ~36-40 s. Wall-clock
74
+ swings by several seconds with co-tenant load, so compare **within a batch** or use
75
+ the profile totals.
76
+
77
+ Per-op split of the ~31-32 s sd.cpp sampling (Q4_K-ffn13-rq, conv-direct):
78
+
79
+ | op | time | note |
80
+ |---|---|---|
81
+ | MUL_MAT | ~24 s | main-layer Q4_K AMX; ~4 TFLOPS effective |
82
+ | FLASH_ATTN_EXT | ~4.4 s | F32 tiled kernel |
83
+ | element-wise | ~2.5 s | RMS norm, gate residuals, etc. |
84
+ | VAE decode | ~4.3 s | OpenVINO is ~0.3 s — biggest relative gap |
85
+
86
+ The sampling gap to OpenVINO is only ~15-20%, but the VAE is ~14x slower, so the
87
+ VAE now dominates the remaining distance to parity.
88
+
89
+ ## Next steps
90
+
91
+ - **VAE**: the 1.5 TFLOP F32 decode cannot beat INT8 AMX at F32. The realistic fix is
92
+ an INT8/Q8_0 conv path (implicit-GEMM over the 9 kernel offsets) reusing the AMX
93
+ int8 GEMM, which needs a 2-D flattened conv-weight layout at load time. This is the
94
+ main piece of work left to approach OpenVINO's 0.3 s.
95
+ - **flash-attn**: F32 `simd_gemm` tops out near 0.5 TFLOPS here; a bf16 AVX512
96
+ attention path is the next step.
97
+ - **matmul**: try AMX cache blocking (nb-outer / mb-inner) for the tall-skinny
98
+ main-layer shapes.
99
+
100
+ ## Reproduce
101
+
102
+ ```bash
103
+ ./build.sh llamafile # builds q4k, ffn13 and the -rq model
104
+ SD_CPU_EXTRA_BUFT=1 GGML_CPU_PROFILE=1 work/bin/llamafile/sd-cli \
105
+ --diffusion-model work/models/z_image_turbo-Q4_K-ffn13-rq.gguf \
106
+ --vae work/models/ae.safetensors --llm work/models/Qwen3-4B-Instruct-2507-Q4_K_M.gguf \
107
+ -p "a cute cat" -W 512 -H 512 --steps 8 --seed 42 -t 16 \
108
+ --conv-direct auto,vae=on -o out.png
109
+ ```
tools/q4k.c ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #include "ggml.h"
2
+ #include <stdio.h>
3
+ #include <stdlib.h>
4
+ int main(int argc,char**argv){
5
+ if(argc<3) return 2;
6
+ long nrows=atol(argv[1]), np=atol(argv[2]);
7
+ long n=nrows*np;
8
+ float *src=malloc(n*sizeof(float));
9
+ if(fread(src,sizeof(float),n,stdin)!=(size_t)n){fprintf(stderr,"read fail\n");return 3;}
10
+ size_t nb=ggml_row_size(GGML_TYPE_Q4_K,np);
11
+ unsigned char *dst=malloc(nb*nrows);
12
+ ggml_quantize_init(GGML_TYPE_Q4_K);
13
+ size_t got=ggml_quantize_chunk(GGML_TYPE_Q4_K,src,dst,0,nrows,np,NULL);
14
+ if(got!=nb*nrows){fprintf(stderr,"quant size mismatch %zu %zu\n",got,nb*nrows);return 4;}
15
+ fwrite(dst,1,got,stdout);
16
+ return 0;
17
+ }
tools/requant_refiner.py ADDED
@@ -0,0 +1,91 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Requantize selected GGUF tensors (default: refiner FFN/attention weights) to Q4_K.
3
+
4
+ The reference Z-Image-Turbo Q4_K GGUF stores the two noise-refiner and two
5
+ context-refiner blocks as Q8_0. On a memory-bound CPU GEMM those 8-bit weights
6
+ make the refiner matmuls ~2x slower than the Q4_K main layers even though the
7
+ token count is smaller. This tool rewrites just the matching Q8_0 tensors as
8
+ Q4_K, leaving every other tensor byte-for-byte untouched.
9
+
10
+ Requires the small `q4k` helper (build with build.sh or see README) which wraps
11
+ ggml's own Q4_K quantizer.
12
+ """
13
+ import argparse
14
+ import os
15
+ import subprocess
16
+ import sys
17
+ import tempfile
18
+
19
+ import numpy as np
20
+ import gguf
21
+ from gguf import GGUFReader, GGUFWriter, GGUFValueType
22
+
23
+ Q4K = os.environ.get("Q4K", "/tmp/q4k")
24
+
25
+
26
+ def q4k_quantize(f32: np.ndarray, helper: str) -> np.ndarray:
27
+ nrows, n_per_row = f32.shape
28
+ p = subprocess.run([helper, str(nrows), str(n_per_row)],
29
+ input=np.ascontiguousarray(f32, dtype=np.float32).tobytes(),
30
+ stdout=subprocess.PIPE, check=True)
31
+ return np.frombuffer(p.stdout, dtype=np.uint8)
32
+
33
+
34
+ def main() -> int:
35
+ ap = argparse.ArgumentParser()
36
+ ap.add_argument("src")
37
+ ap.add_argument("dst")
38
+ ap.add_argument("--pattern", default="refiner")
39
+ ap.add_argument("--only-kinds", default="attention,feed_forward",
40
+ help="comma list of substrings a tensor name must contain (empty = any)")
41
+ ap.add_argument("--helper", default=Q4K)
42
+ ap.add_argument("--dry-run", action="store_true")
43
+ args = ap.parse_args()
44
+
45
+ r = GGUFReader(args.src)
46
+ arch = str(r.fields["general.architecture"].contents()) if "general.architecture" in r.fields else "zimage"
47
+
48
+ # pick targets
49
+ kinds = [k for k in args.only_kinds.split(",") if k]
50
+ targets = []
51
+ for t in r.tensors:
52
+ if args.pattern in t.name and t.tensor_type == gguf.GGMLQuantizationType.Q8_0:
53
+ if kinds and not any(k in t.name for k in kinds):
54
+ continue
55
+ targets.append(t.name)
56
+ print(f"targets ({len(targets)}):")
57
+ for n in targets:
58
+ print(" ", n)
59
+ if args.dry_run or not targets:
60
+ return 0
61
+
62
+ w = GGUFWriter(args.dst, arch, use_temp_file=True)
63
+ for name, field in r.fields.items():
64
+ if name == "general.architecture" or name.startswith("GGUF."):
65
+ continue
66
+ v = field.contents()
67
+ w.add_key_value(name, v, field.types[0],
68
+ sub_type=field.types[-1] if len(field.types) > 1 else None)
69
+
70
+ tset = set(targets)
71
+ for t in r.tensors:
72
+ if t.name in tset:
73
+ f32 = gguf.quants.dequantize(t.data, t.tensor_type)
74
+ raw = q4k_quantize(f32, args.helper)
75
+ tgt = int(t.shape[0]) // 256 * 144 # Q4_K bytes per row (ne0 = input width)
76
+ w.add_tensor(t.name, raw.reshape(-1, tgt),
77
+ raw_dtype=gguf.GGMLQuantizationType.Q4_K)
78
+ print(f" requantized {t.name} {tuple(t.shape)} Q8_0->Q4_K "
79
+ f"{t.n_bytes} -> {raw.nbytes} bytes")
80
+ else:
81
+ w.add_tensor(t.name, t.data, raw_dtype=t.tensor_type)
82
+ w.write_header_to_file()
83
+ w.write_kv_data_to_file()
84
+ w.write_tensors_to_file()
85
+ w.close()
86
+ print("wrote", args.dst)
87
+ return 0
88
+
89
+
90
+ if __name__ == "__main__":
91
+ sys.exit(main())