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 +15 -0
- SmartTest.sh +3 -0
- build.sh +20 -0
- patches/ggml-0002-flash-attn-kpack.patch +62 -4
- patches/ggml-0003-flash-attn-tiles.patch +15 -0
- stages/stage_p2_refiner/README.md +109 -0
- tools/q4k.c +17 -0
- tools/requant_refiner.py +91 -0
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..
|
| 3 |
--- a/src/ggml-cpu/ops.cpp
|
| 4 |
+++ b/src/ggml-cpu/ops.cpp
|
| 5 |
-
@@ -9238,10 +9238,
|
| 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 +
|
| 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 +
|
| 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())
|