include llama.cpp since there were some edits

This commit is contained in:
zefie committed 2026-11-01 16:32:35 -05:00
1 parent db7f28d087
commit 8c66990853
3918 files changed
+1640170 -1

No files matched your search

@@ -0,0 +1 @@
0xffff
+426
View File
@@ -0,0 +1,426 @@
#!/usr/bin/env python3
#
# Build llama.cpp for Snapdragon (via Docker or natively) and push to device.
#
import sys
import os
import argparse
import subprocess
import platform
import shutil
import logging
import glob
import shlex
import tarfile
import tempfile
from sdk import validate_windows_sdks
logger = logging.getLogger("build")
def parse_target(target_str):
if not target_str:
return None, None, None
parts = target_str.split(":")
target_name = parts[0]
if target_name in ("adb", "android"):
serial = ":".join(parts[1:]) if len(parts) > 1 else None
return "android", serial, "adb"
elif target_name in ("lnx", "linux", "ubuntu"):
if len(parts) > 1 and parts[1] == "adb":
serial = ":".join(parts[2:]) if len(parts) > 2 else None
return "linux", serial, "adb"
host = ":".join(parts[1:]) if len(parts) > 1 else None
return "linux", host, "ssh"
elif target_str in ("wos", "windows"):
return "windows", None, None
else:
return None, None, None
def default_target_dir(target_type, target_transport, home_dir):
if target_type == "android":
return "/data/local/tmp/llama.cpp"
if target_type == "linux":
if home_dir:
return home_dir.rstrip("/") + "/llama.cpp"
if target_transport == "adb":
return "/home/llama.cpp"
return "~/llama.cpp"
return "~/llama.cpp"
def normalize_target_dir(target_dir):
target_dir = target_dir.rstrip("/")
if not target_dir or target_dir == "/":
raise RuntimeError("target directory must not be the filesystem root")
return target_dir
def adb_root(adb_cmd):
root_cmd = adb_cmd + ["root"]
logger.info(f"+ {' '.join(root_cmd)}")
res = subprocess.run(root_cmd)
if res.returncode != 0:
logger.error("ADB root failed.")
sys.exit(res.returncode)
wait_cmd = adb_cmd + ["wait-for-device"]
logger.info(f"+ {' '.join(wait_cmd)}")
res = subprocess.run(wait_cmd)
if res.returncode != 0:
logger.error("ADB wait-for-device failed.")
sys.exit(res.returncode)
def make_package_archive(src_path):
fd, archive_path = tempfile.mkstemp(prefix="llama-cpp-", suffix=".tar.gz")
os.close(fd)
with tarfile.open(archive_path, "w:gz", dereference=False) as tar:
for item in os.listdir(src_path):
if item.startswith("."):
continue
tar.add(os.path.join(src_path, item), arcname=item, recursive=True)
return archive_path
def get_uid_gid():
if platform.system() != "Windows":
return [f"{os.getuid()}:{os.getgid()}"]
return []
def main():
logging.basicConfig(level=logging.INFO, format='%(message)s')
parser = argparse.ArgumentParser(
description="Build llama.cpp for Snapdragon using cross-compilation docker containers or natively."
)
parser.add_argument("--target", default="android", help="Compilation target and deployment definition (e.g. android[:serial]/adb[:serial], ubuntu:adb[:serial], linux:[user@]host/lnx:[user@]host/ubuntu:[user@]host, windows/wos) (default: android)")
parser.add_argument("--build-dir", help="Build directory name (defaults to build-TARGET[-dbg], e.g. build-android)")
parser.add_argument("--install-dir", help="Install directory name (defaults to pkg-TARGET[-dbg], e.g. pkg-android)")
parser.add_argument("--jobs", "-j", type=int, help="Number of build jobs (defaults to CPU thread count)")
parser.add_argument("--no-docker", action="store_true", help="Build natively on the host instead of in a docker container")
parser.add_argument("--preset", help="Override the CMake preset to use")
parser.add_argument("--debug", action="store_true", help="Build in debug mode (uses -debug presets instead of -release)")
parser.add_argument("--hex-only", "--hexagon-only", action="store_true", help="Only build and push ggml-hexagon and libggml-htp libraries")
parser.add_argument("--hex-arch", choices=["v73", "v75", "v79", "v81", "v85", "73", "75", "79", "81", "85"], help="Specific Hexagon DSP architecture to build with --hex-only (default: all)")
# Push options
parser.add_argument("--push", action="store_true", help="Push built package to the target device via ADB or SSH/SCP")
parser.add_argument("--target-dir", help="Target directory on the device (default: /data/local/tmp/llama.cpp for Android, ~/llama.cpp for Linux, /home/llama.cpp for Linux via ADB)")
parser.add_argument("--home-dir", help="Home directory for Linux targets when deriving the default target directory")
# Toolchain options
parser.add_argument("--toolchain-version", default="v0.8", help="Docker toolchain image version/tag (default: v0.8)")
parser.add_argument("--toolchain-url", default="ghcr.io/snapdragon-toolchain", help="Docker toolchain registry URL/namespace (default: ghcr.io/snapdragon-toolchain)")
args = parser.parse_args()
target_type, target_val, target_transport = parse_target(args.target)
if not target_type:
logger.error(f"Error: Invalid target format '{args.target}'. Must be android[:serial]/adb[:serial], ubuntu:adb[:serial], linux:[user@]host/lnx:[user@]host/ubuntu:[user@]host, or windows/wos.")
sys.exit(1)
if target_type == "windows":
logger.info("Windows target selected. Forcing native compilation...")
args.no_docker = True
if platform.system() != "Windows":
logger.warning("Warning: Windows compilation is intended to run on Windows arm64 hosts.")
validate_windows_sdks()
# Determine preset and check if it's debug
preset = args.preset
if preset:
is_debug = args.debug or ("debug" in preset.lower())
else:
is_debug = args.debug
config_type = "debug" if is_debug else "release"
if args.no_docker:
if target_type == "windows" or platform.system() == "Windows":
preset = f"arm64-windows-snapdragon-{config_type}"
elif target_type == "linux":
preset = f"arm64-linux-snapdragon-{config_type}"
else:
preset = f"arm64-android-snapdragon-{config_type}"
else:
preset = f"arm64-linux-snapdragon-{config_type}" if target_type == "linux" else f"arm64-android-snapdragon-{config_type}"
target_prefix = args.target.split(":", 1)[0]
suffix = "-dbg" if is_debug else ""
build_dir = args.build_dir
if not build_dir:
build_dir = f"build-{target_prefix}{suffix}"
install_dir = args.install_dir
if not install_dir:
install_dir = f"pkg-{target_prefix}{suffix}"
repo_root = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
# Ensure CMakeUserPresets.json is in the workspace root, update if docs version is newer
preset_src = os.path.join(repo_root, "docs", "backend", "snapdragon", "CMakeUserPresets.json")
preset_dst = os.path.join(repo_root, "CMakeUserPresets.json")
if os.path.exists(preset_src):
should_copy = False
if not os.path.exists(preset_dst):
should_copy = True
else:
# Check modification times
src_mtime = os.path.getmtime(preset_src)
dst_mtime = os.path.getmtime(preset_dst)
if src_mtime > dst_mtime:
preset_bak = preset_dst + ".bak"
logger.info(f"Docs CMakeUserPresets.json is newer. Backing up existing {preset_dst} to {preset_bak}")
shutil.copy2(preset_dst, preset_bak)
should_copy = True
if should_copy:
logger.info(f"Copying CMakeUserPresets.json from {preset_src} to {preset_dst}")
shutil.copy2(preset_src, preset_dst)
else:
logger.warning("Warning: CMakeUserPresets.json not found in docs/backend/snapdragon/.")
jobs = args.jobs if args.jobs else os.cpu_count() or 4
hex_arch = None
if args.hex_arch:
hex_arch = args.hex_arch if args.hex_arch.startswith("v") else f"v{args.hex_arch}"
if args.hex_only:
if hex_arch:
hex_targets = ["ggml-hexagon", f"htp-{hex_arch}"]
else:
hex_targets = ["ggml-hexagon", "htp-v73", "htp-v75", "htp-v79", "htp-v81", "htp-v85"]
if args.no_docker:
# Native/local host build
logger.info("Running native/local CMake build...")
install_prefix = os.path.join(repo_root, install_dir, "llama.cpp")
# Configure
configure_cmd = ["cmake", f"--preset={preset}", "-B", build_dir]
logger.info(f"+ {' '.join(configure_cmd)}")
res = subprocess.run(configure_cmd, cwd=repo_root)
if res.returncode != 0:
logger.error("CMake configuration failed.")
sys.exit(res.returncode)
# Build
build_cmd = ["cmake", "--build", build_dir]
if args.hex_only:
build_cmd += ["--target"] + hex_targets
build_cmd += ["-j", str(jobs)]
logger.info(f"+ {' '.join(build_cmd)}")
res = subprocess.run(build_cmd, cwd=repo_root)
if res.returncode != 0:
logger.error("CMake build failed.")
sys.exit(res.returncode)
# Install
install_cmd = ["cmake", "--install", build_dir, "--prefix", install_prefix]
logger.info(f"+ {' '.join(install_cmd)}")
res = subprocess.run(install_cmd, cwd=repo_root)
if res.returncode != 0:
logger.error("CMake install failed.")
sys.exit(res.returncode)
else:
# Docker-based build
logger.info("Running Docker-based cross-compilation build...")
image_name = "arm64-linux" if target_type == "linux" else "arm64-android"
image = f"{args.toolchain_url}/{image_name}:{args.toolchain_version}"
install_prefix_container = f"/workspace/{install_dir}/llama.cpp"
target_args = f"--target {' '.join(hex_targets)} " if args.hex_only else ""
build_sh_cmd = (
f"cmake --preset {preset} -B /workspace/{build_dir} && "
f"cmake --build /workspace/{build_dir} {target_args}-j {jobs} && "
f"cmake --install /workspace/{build_dir} --prefix {install_prefix_container}"
)
docker_cmd = [
"docker", "run", "--rm",
"--volume", f"{repo_root}:/workspace",
"--workdir", "/workspace",
"--platform", "linux/amd64"
]
uid_gid = get_uid_gid()
if uid_gid:
docker_cmd += ["-u", uid_gid[0]]
docker_cmd += [image, "bash", "-c", build_sh_cmd]
logger.info(f"+ {' '.join(docker_cmd)}")
res = subprocess.run(docker_cmd, cwd=repo_root)
if res.returncode != 0:
logger.error("Docker-based build failed.")
sys.exit(res.returncode)
logger.info("\nBuild and installation completed successfully!")
# Push/deploy if requested
if args.push:
src_path = os.path.join(repo_root, install_dir, "llama.cpp")
if not os.path.exists(src_path):
logger.error(f"Error: installation directory {src_path} does not exist. Cannot deploy.")
sys.exit(1)
# Resolve target directory on device
target_dir = args.target_dir
if not target_dir:
target_dir = default_target_dir(target_type, target_transport, args.home_dir)
target_dir = normalize_target_dir(target_dir)
sub_items = [item for item in os.listdir(src_path) if not item.startswith(".")]
if args.hex_only:
lib_dir = os.path.join(src_path, "lib")
hex_libs = glob.glob(os.path.join(lib_dir, "libggml-hexagon*"))
if hex_arch:
hex_libs += glob.glob(os.path.join(lib_dir, f"libggml-htp-{hex_arch}*"))
else:
hex_libs += glob.glob(os.path.join(lib_dir, "libggml-htp*"))
if not hex_libs:
logger.error(f"Error: no hexagon libraries found in {lib_dir} to push.")
sys.exit(1)
if target_transport == "adb":
adb_cmd = ["adb"]
if target_val: # serial
adb_cmd += ["-s", target_val]
if target_type == "linux":
adb_root(adb_cmd)
if args.hex_only:
logger.info("\nPushing Hexagon libraries to target device via ADB...")
target_lib_dir = f"{target_dir}/lib"
mkdir_cmd = adb_cmd + ["shell", f"mkdir -p {shlex.quote(target_lib_dir)}"]
logger.info(f"+ {' '.join(mkdir_cmd)}")
res = subprocess.run(mkdir_cmd)
if res.returncode != 0:
logger.error("ADB mkdir failed.")
sys.exit(res.returncode)
for lib in hex_libs:
push_cmd = adb_cmd + ["push", lib, f"{target_lib_dir}/"]
logger.info(f"+ {' '.join(push_cmd)}")
res = subprocess.run(push_cmd)
if res.returncode != 0:
logger.error("ADB push failed.")
sys.exit(res.returncode)
logger.info("Hexagon libraries deployed successfully!")
elif target_type == "linux":
logger.info("\nDeploying Linux package to target device via ADB tar...")
remote_archive = f"/tmp/llama.cpp-package-{os.getpid()}.tar.gz"
archive_path = make_package_archive(src_path)
try:
mkdir_cmd = adb_cmd + ["shell", f"mkdir -p {shlex.quote(target_dir)}"]
logger.info(f"+ {' '.join(mkdir_cmd)}")
res = subprocess.run(mkdir_cmd)
if res.returncode != 0:
logger.error("ADB mkdir failed.")
sys.exit(res.returncode)
if sub_items:
clean_paths = " ".join(shlex.quote(f"{target_dir}/{item}") for item in sub_items)
clean_cmd = adb_cmd + ["shell", f"rm -rf {clean_paths}"]
logger.info(f"+ {' '.join(clean_cmd)}")
subprocess.run(clean_cmd)
push_cmd = adb_cmd + ["push", archive_path, remote_archive]
logger.info(f"+ {' '.join(push_cmd)}")
res = subprocess.run(push_cmd)
if res.returncode != 0:
logger.error("ADB push failed.")
sys.exit(res.returncode)
extract_cmd = adb_cmd + ["shell", f"tar -xzf {shlex.quote(remote_archive)} -C {shlex.quote(target_dir)} && rm -f {shlex.quote(remote_archive)}"]
logger.info(f"+ {' '.join(extract_cmd)}")
res = subprocess.run(extract_cmd)
if res.returncode != 0:
logger.error("ADB untar failed.")
sys.exit(res.returncode)
chmod_cmd = adb_cmd + ["shell", f"chmod -R 755 {shlex.quote(target_dir)}/bin 2>/dev/null || true"]
logger.info(f"+ {' '.join(chmod_cmd)}")
subprocess.run(chmod_cmd)
finally:
os.unlink(archive_path)
logger.info("ADB tar deploy completed successfully!")
else:
# Clean stale package files on device
if sub_items:
clean_paths = " ".join(f"{target_dir}/{item}" for item in sub_items)
clean_cmd = adb_cmd + ["shell", f"rm -rf {clean_paths}"]
logger.info(f"+ {' '.join(clean_cmd)}")
subprocess.run(clean_cmd)
# Android destination directory is target_dir
push_cmd = adb_cmd + ["push", os.path.join(src_path, "."), target_dir]
logger.info(f"+ {' '.join(push_cmd)}")
res = subprocess.run(push_cmd)
if res.returncode != 0:
logger.error("ADB push failed.")
sys.exit(res.returncode)
chmod_cmd = adb_cmd + ["shell", f"chmod -R 755 {target_dir}/bin 2>/dev/null || true"]
logger.info(f"+ {' '.join(chmod_cmd)}")
subprocess.run(chmod_cmd)
logger.info("ADB push completed successfully!")
elif target_type == "linux":
ssh_host = target_val
if not ssh_host:
logger.error("Error: SSH host not specified in target (e.g. use linux:user@host, lnx:user@host, or ubuntu:user@host). Cannot deploy.")
sys.exit(1)
if args.hex_only:
logger.info(f"\nDeploying Hexagon libraries to Linux device {ssh_host} via SCP...")
target_lib_dir = f"{target_dir}/lib"
deploy_cmd = ["scp"] + hex_libs + [f"{ssh_host}:{target_lib_dir}/"]
logger.info(f"+ {' '.join(deploy_cmd)}")
res = subprocess.run(deploy_cmd)
if res.returncode != 0:
logger.error("SCP deploy failed.")
sys.exit(res.returncode)
logger.info("Hexagon libraries deployed successfully!")
else:
# Clean stale package files on remote host
if sub_items:
clean_paths = " ".join(f"{target_dir}/{item}" for item in sub_items)
clean_cmd = ["ssh", ssh_host, f"rm -rf {clean_paths}"]
logger.info(f"+ {' '.join(clean_cmd)}")
subprocess.run(clean_cmd)
# Deploy to target_dir
deploy_cmd = ["scp", "-r", os.path.join(src_path, "."), f"{ssh_host}:{target_dir}"]
logger.info(f"+ {' '.join(deploy_cmd)}")
res = subprocess.run(deploy_cmd)
if res.returncode != 0:
logger.error("SSH/SCP deploy failed.")
sys.exit(res.returncode)
chmod_cmd = ["ssh", ssh_host, f"chmod -R 755 {target_dir}/bin 2>/dev/null || true"]
logger.info(f"+ {' '.join(chmod_cmd)}")
subprocess.run(chmod_cmd)
logger.info("SSH/SCP deploy completed successfully!")
elif target_type == "windows":
logger.info("\nPush for Windows on Snapdragon (windows) target is currently a stub.")
if __name__ == "__main__":
try:
main()
except KeyboardInterrupt:
logger.info("\nInterrupted by user.")
sys.exit(130)
except RuntimeError as err:
logger.error("Error: %s", err)
sys.exit(1)
+296
View File
@@ -0,0 +1,296 @@
#!/usr/bin/env python3
"""
align-macros.py - Inspect and align trailing backslashes in multiline C/C++ macros.
Usage:
align-macros.py [paths...] # Check and report misaligned macros
align-macros.py --diff [paths...] # Show unified diff of fixes
align-macros.py --fix [paths...] # Fix misaligned macros in-place
align-macros.py --fix --mode majority ... # Align to the dominant column
align-macros.py --fix --pad 2 ... # Align to (max_content_len + pad)
Safety rules:
- Macros that are ALREADY aligned are NEVER touched (unless --all is given).
- Whitespace after trailing backslashes is flagged and cleaned.
"""
import argparse
import difflib
import logging
import os
import re
import sys
from collections import Counter
from typing import List, Optional, Tuple, NamedTuple
logger = logging.getLogger("ggml-hexagon-align-macros")
class MacroLine(NamedTuple):
line_num: int # 1-indexed
raw: str # Original line including newline
content: str # Line content before trailing backslash (stripped of trailing whitespace)
bs_col: Optional[int] # 1-indexed column of backslash, or None if last line has no backslash
trailing_ws: bool # True if whitespace existed after the backslash
class MacroDef(NamedTuple):
name: str
filepath: str
start_line: int
end_line: int
lines: List[MacroLine]
def parse_macros(filepath: str) -> List[MacroDef]:
"""Extract all multiline macros from a C/C++ source file."""
try:
with open(filepath, "r", encoding="utf-8", errors="replace") as f:
lines = f.readlines()
except Exception as e:
logger.error(f"Error reading {filepath}: {e}")
return []
macros: List[MacroDef] = []
i = 0
n = len(lines)
while i < n:
line = lines[i]
m = re.match(r"^\s*#\s*define\s+([A-Za-z_][A-Za-z0-9_]*)", line)
if m:
macro_name = m.group(1)
macro_start = i + 1
macro_lines: List[MacroLine] = []
cur = i
while cur < n:
l_raw = lines[cur]
l_rstrip = l_raw.rstrip("\r\n")
# Check if line has a trailing backslash
# Note: handle possible accidental spaces after backslash
match_bs = re.search(r"\\([ \t]*)$", l_rstrip)
if match_bs:
has_trailing_ws = len(match_bs.group(1)) > 0
bs_index = match_bs.start()
content = l_rstrip[:bs_index].rstrip()
# 1-indexed column of the backslash
bs_col = bs_index + 1
macro_lines.append(MacroLine(
line_num=cur + 1,
raw=l_raw,
content=content,
bs_col=bs_col,
trailing_ws=has_trailing_ws
))
cur += 1
else:
# Line does not end with backslash
if cur == i:
# Single-line macro, not multiline
break
else:
# Final line of a multiline macro
macro_lines.append(MacroLine(
line_num=cur + 1,
raw=l_raw,
content=l_rstrip.rstrip(),
bs_col=None,
trailing_ws=False
))
break
# Only record if it is a multiline macro (has at least one continuation line)
continuation_lines = [ml for ml in macro_lines if ml.bs_col is not None]
if continuation_lines:
macro_end = macro_lines[-1].line_num
macros.append(MacroDef(
name=macro_name,
filepath=filepath,
start_line=macro_start,
end_line=macro_end,
lines=macro_lines
))
i = cur
i += 1
return macros
def is_macro_aligned(macro: MacroDef) -> bool:
"""A macro is aligned if all continuation lines have backslashes at the same column."""
bs_cols = [ml.bs_col for ml in macro.lines if ml.bs_col is not None]
if not bs_cols:
return True
has_trailing_ws = any(ml.trailing_ws for ml in macro.lines)
return len(set(bs_cols)) == 1 and not has_trailing_ws
def compute_target_column(macro: MacroDef, mode: str, pad: int, target_col: Optional[int]) -> int:
"""Determine the column where backslashes should be aligned."""
max_content_len = max(len(ml.content) for ml in macro.lines)
min_needed = max_content_len + pad
if target_col is not None:
return max(target_col, min_needed)
bs_cols = [ml.bs_col for ml in macro.lines if ml.bs_col is not None]
if not bs_cols:
return min_needed
if mode == "min":
return min_needed
elif mode == "max":
return max(max(bs_cols), min_needed)
elif mode == "majority":
counts = Counter(bs_cols)
# Sort by frequency descending, then by column descending
majority_col = sorted(counts.items(), key=lambda x: (-x[1], -x[0]))[0][0]
return max(majority_col, min_needed)
else:
return min_needed
def realign_macro_lines(macro: MacroDef, target_col: int) -> List[str]:
"""Format macro lines with backslashes aligned at target_col."""
new_lines: List[str] = []
for ml in macro.lines:
nl = "\r\n" if ml.raw.endswith("\r\n") else "\n"
if ml.bs_col is None:
# Last line without backslash
new_lines.append(ml.raw)
else:
if not ml.content:
spaces = " " * (target_col - 1)
new_lines.append(f"{spaces}\\{nl}")
else:
spaces_needed = max(1, target_col - len(ml.content) - 1)
new_lines.append(f"{ml.content}{' ' * spaces_needed}\\{nl}")
return new_lines
def process_file(filepath: str, args: argparse.Namespace) -> Tuple[int, int, Optional[str]]:
macros = parse_macros(filepath)
if not macros:
return 0, 0, None
with open(filepath, "r", encoding="utf-8", errors="replace") as f:
file_lines = f.readlines()
misaligned_count = 0
modified = False
new_file_lines = list(file_lines)
for macro in macros:
aligned = is_macro_aligned(macro)
if not aligned or args.all:
if not aligned:
misaligned_count += 1
bs_cols = [ml.bs_col for ml in macro.lines if ml.bs_col is not None]
max_content = max(len(ml.content) for ml in macro.lines)
col_counts = Counter(bs_cols)
if not args.quiet:
logger.info(f"{filepath}:{macro.start_line}-{macro.end_line} [{macro.name}]")
logger.info(f" Max content width: {max_content}, Min needed column (+{args.pad}): {max_content + args.pad}")
logger.info(f" Current backslash columns: {dict(sorted(col_counts.items()))}")
trailing_ws_lines = [ml.line_num for ml in macro.lines if ml.trailing_ws]
if trailing_ws_lines:
logger.warning(f" Warning: Trailing whitespace after backslash on line(s): {trailing_ws_lines}")
target_col = compute_target_column(macro, args.mode, args.pad, args.target_col)
if not args.quiet:
logger.info(f" -> Target alignment column: {target_col}")
realigned = realign_macro_lines(macro, target_col)
start_idx = macro.start_line - 1
end_idx = start_idx + len(macro.lines)
if new_file_lines[start_idx:end_idx] != realigned:
new_file_lines[start_idx:end_idx] = realigned
modified = True
diff_text = None
if modified:
diff = difflib.unified_diff(
file_lines,
new_file_lines,
fromfile=f"a/{filepath}",
tofile=f"b/{filepath}",
lineterm=""
)
diff_text = "\n".join(diff)
if args.fix:
with open(filepath, "w", encoding="utf-8") as f:
f.writelines(new_file_lines)
if not args.quiet:
logger.info(f" [FIXED] Updated {filepath}")
return len(macros), misaligned_count, diff_text
def find_source_files(paths: List[str]) -> List[str]:
extensions = {".c", ".cpp", ".cc", ".cxx", ".h", ".hpp", ".inl"}
result: List[str] = []
for p in paths:
if os.path.isfile(p):
result.append(p)
elif os.path.isdir(p):
for root, _, files in os.walk(p):
for file in sorted(files):
_, ext = os.path.splitext(file)
if ext.lower() in extensions:
result.append(os.path.join(root, file))
return sorted(result)
def main():
logging.basicConfig(level=logging.INFO, format="%(message)s")
parser = argparse.ArgumentParser(
description="Inspect and align backslashes in multiline C/C++ macros."
)
parser.add_argument("paths", nargs="*", default=["."], help="Files or directories to scan (default: current dir)")
parser.add_argument("--fix", action="store_true", help="Fix misaligned macros in-place")
parser.add_argument("--diff", action="store_true", help="Display unified diff of suggested fixes")
parser.add_argument("--check", action="store_true", help="Exit with code 1 if misaligned macros exist")
parser.add_argument("--mode", choices=["min", "max", "majority"], default="min",
help="Alignment mode: 'min' (max_len + pad), 'max' (max existing col), 'majority' (dominant col)")
parser.add_argument("--pad", type=int, default=2, help="Spaces between longest line and backslash (default: 2)")
parser.add_argument("--target-col", type=int, default=None, help="Force alignment to an exact column")
parser.add_argument("--all", action="store_true", help="Realign all macros even if already aligned (default: only misaligned)")
parser.add_argument("-q", "--quiet", action="store_true", help="Only output errors and diffs/summary")
args = parser.parse_args()
files = find_source_files(args.paths)
if not files:
logger.error("No C/C++ source files found.")
sys.exit(0)
total_macros = 0
total_misaligned = 0
diffs: List[str] = []
for filepath in files:
num_macros, num_misaligned, diff_text = process_file(filepath, args)
total_macros += num_macros
total_misaligned += num_misaligned
if diff_text:
diffs.append(diff_text)
if args.diff and diffs:
logger.info("\n--- Proposed Changes ---\n")
for d in diffs:
logger.info(d)
logger.info(f"\nSummary: scanned {len(files)} files, {total_macros} multiline macros, {total_misaligned} misaligned.")
if args.check and total_misaligned > 0:
sys.exit(1)
if __name__ == "__main__":
main()
File diff suppressed because it is too large. Load diff
+901
View File
@@ -0,0 +1,901 @@
#!/usr/bin/env python3
import sys
import os
import re
import argparse
import statistics
import logging
import bisect
from typing import Any, Dict, List, Optional, Iterable
from collections import defaultdict
# Mapping of cli-friendly names to (internal_data_key, Display Header, numeric_sort_key)
COL_MAP = {
"tot-usec": ("tot_usec", "Tot usec", "_sort_tot_usec"),
"op": ("op", "Op", "op"),
"dims": ("dims", "Dims", "dims"),
"dtypes": ("dtypes", "DTypes", "dtypes"),
"count": ("count", "Count", "_sort_count"),
"max-usec": ("max_usec", "Max usec", "_sort_max_usec"),
"avg-usec": ("avg_usec", "Avg usec", "_sort_avg_usec"),
"max-cycles": ("max_cycles", "Max Cycles", "_sort_max_cycles"),
"avg-cycles": ("avg_cycles", "Avg Cycles", "_sort_avg_cycles"),
"max-pmu": ("max_pmu", "Max PMU", "_sort_max_pmu"),
"avg-pmu": ("avg_pmu", "Avg PMU", "_sort_avg_pmu"),
}
op_pattern = re.compile(
r"profile-op\s+(?P<op_name>[A-Z_0-9+]+):\s+.*?\s+:\s+(?P<dims>[\d:x\s\->!]+)\s+:\s+(?P<types>[a-z\d_\s\->x]+)\s+:\s+.*?\s+:\s+(?:op-)?usec\s+(?P<usec>\d+)\s+(?:op-)?cycles\s+(?P<cycles>\d+)(?:\s+start\s+(?P<start>\d+))?(?:\s+mhz\s+(?P<mhz>[\d.]+))?(?:\s+pmu\s+\[(?P<pmu>[\d,\s]+)\])?(?:\s+evt\s+\[(?P<evt>[\d,\s]+)\])?"
)
trace_pattern = re.compile(
r"trace-evt\s+(?P<event>[A-Z_0-9\-]+):\s+thread\s+(?P<thread>\d+)\s+info\s+(?P<info>\d+)\s+(?P<state>start|stop)\s+(?P<cycles>\d+)"
)
device_pattern = re.compile(r"\b(HTP\d+(?::\d+)?)\s+(?:profile-op|trace-evt)\b")
def extract_device(line):
m = device_pattern.search(line)
if m:
return m.group(1)
return "HTP0"
def device_matches(record_device, target_device):
targets = [t.strip() for t in target_device.split(',')]
for target in targets:
if record_device == target:
return True
if record_device.startswith(target + ":"):
return True
return False
logging.basicConfig(level=logging.INFO, format="%(message)s", stream=sys.stdout)
logger = logging.getLogger("ggml-hexagon-profile")
def normalize_event_name(evt_type):
if evt_type == "HVX_COMP":
return "V-COMP"
if evt_type == "HMX_COMP":
return "M-COMP"
# Strip HVX_ or HMX_ prefixes
name = evt_type
if name.startswith("HVX_") or name.startswith("HMX_"):
name = name[4:]
return name.replace("_", "-")
class CycleUnwrapper:
def __init__(self, initial_val=None):
if initial_val is not None:
self.last_raw = initial_val & 0xFFFFFFFF
self.high_part = initial_val & 0xFFFFFFFF00000000
else:
self.last_raw = None
self.high_part = 0
def unwrap(self, raw):
if self.last_raw is None:
self.last_raw = raw
return raw
diff = raw - self.last_raw
if diff < -0x80000000:
self.high_part += 0x100000000
elif diff > 0x80000000:
self.high_part -= 0x100000000
self.last_raw = raw
return raw + self.high_part
def parse_log(file_path, pmu_index=None, limit=None, device_filter=None, op_filter_re=None):
try:
if file_path != "-":
f = open(file_path, 'r', encoding='utf-8', errors='ignore')
else:
f = os.fdopen(0, 'r', encoding='utf-8', errors='ignore')
except FileNotFoundError:
logger.error(f"file '{file_path}' not found.")
sys.exit(1)
all_ops: List[Dict[str, Any]] = []
all_traces: List[Dict[str, Any]] = []
current_op: Optional[Dict[str, Any]] = None
ops_count_per_device = {}
if device_filter is not None:
for target in device_filter.split(','):
ops_count_per_device[target.strip()] = 0
limit_reached = False
timestamp_pattern = re.compile(r"(?P<min>\d+)\.(?P<sec>\d+)\.(?P<ms>\d+)\.(?P<us>\d+)\s+[A-Z]\s+")
unwrappers = {}
last_batch_start = {}
trace_unwrappers = {}
for line in f:
if "profile-op" not in line and "trace-evt" not in line:
continue
ts_match = timestamp_pattern.search(line)
abs_usec = 0
if ts_match:
abs_usec = (
(int(ts_match.group('min')) * 60 + int(ts_match.group('sec'))) * 1000000
+ int(ts_match.group('ms')) * 1000
+ int(ts_match.group('us'))
)
device = extract_device(line)
idx = line.find("profile-op")
if idx != -1 and "|" in line[idx:]:
parts = [p.strip() for p in line[idx:].split("|")]
prefix = parts[0]
prefix_match = re.search(r"profile-op\s+(?P<op_name>[A-Z_0-9+]+)", prefix)
if not prefix_match:
continue
names = parts[1]
if len(parts) == 7:
dims, types, timings = parts[2], parts[3], parts[6]
elif len(parts) == 6:
dims, types, timings = parts[2], parts[3], parts[5]
else:
continue
timing_match = re.search(
r"(?:op-)?usec\s+(?P<usec>\d+)\s+(?:op-)?cycles\s+(?P<cycles>\d+)(?:\s+start\s+(?P<start>\d+))?(?:\s+mhz\s+(?P<mhz>[\d.]+))?(?:\s+pmu\s+\[(?P<pmu>[\d,\s]+)\])?(?:\s+evt\s+\[(?P<evt>[\d,\s]+)\])?",
timings
)
if not timing_match:
continue
op_match = timing_match
op_name = prefix_match.group("op_name")
else:
op_match = op_pattern.search(line)
if op_match:
op_name = op_match.group('op_name')
names = ""
dims = op_match.group('dims').strip()
types = op_match.group('types').strip()
else:
op_match = None
if op_match:
pmu_raw = op_match.group('pmu') if 'pmu' in op_match.groupdict() else None
pmu_val = None
if pmu_raw and pmu_index is not None:
try:
pmu_list = [int(x.strip()) for x in pmu_raw.split(',')]
if len(pmu_list) > pmu_index:
pmu_val = pmu_list[pmu_index]
except (ValueError, IndexError):
pmu_val = None
evt_val = None
if types.startswith("evt-cnt "):
try:
evt_val = [int(x.strip()) for x in types[8:].split(',')]
except ValueError:
evt_val = None
cycles_start_raw = op_match.group('start')
unwrapped_cycles_start = None
if op_name == "OPBATCH":
if cycles_start_raw:
unwrapped_cycles_start = int(cycles_start_raw)
unwrappers[device] = CycleUnwrapper(unwrapped_cycles_start)
last_batch_start[device] = unwrapped_cycles_start
for k in list(trace_unwrappers.keys()):
if k[0] == device:
del trace_unwrappers[k]
else:
if cycles_start_raw:
device_unwrapper = unwrappers.get(device)
if device_unwrapper is not None:
unwrapped_cycles_start = device_unwrapper.unwrap(int(cycles_start_raw))
op_text = re.sub(r"^profile-op\s+", "", line[idx:]).strip() if idx != -1 else line.strip()
current_op = {
'name': op_name,
'names': names,
'dims': dims,
'types': types,
'op_text': op_text,
'usec': int(op_match.group('usec')),
'cycles': int(op_match.group('cycles')),
'cycles_start': int(cycles_start_raw) if cycles_start_raw else None,
'unwrapped_cycles_start': unwrapped_cycles_start,
'pmu_val': pmu_val,
'evt_val': evt_val,
'mhz': float(op_match.group('mhz')) if op_match.group('mhz') else None,
'abs_usec': abs_usec,
'trace_events': [],
'device': device
}
all_ops.append(current_op)
# Check if matching early exit criteria
matched = False
matched_target = None
if device_filter is not None:
targets = [t.strip() for t in device_filter.split(',')]
for target in targets:
if device == target or device.startswith(target + ":"):
matched = True
matched_target = target
break
else:
matched = True
matched_target = device
if op_filter_re is not None and not op_filter_re.search(op_text):
matched = False
if matched:
if matched_target not in ops_count_per_device:
ops_count_per_device[matched_target] = 0
ops_count_per_device[matched_target] += 1
if limit is not None and len(ops_count_per_device) > 0 and all(count >= limit for count in ops_count_per_device.values()):
limit_reached = True
if limit_reached and op_name == "OPBATCH":
break
continue
trace_match = trace_pattern.search(line)
if trace_match:
thread = int(trace_match.group('thread'))
raw_cyc = int(trace_match.group('cycles'))
unwrapped_cyc = None
th_key = (device, thread)
if th_key not in trace_unwrappers:
batch_start = last_batch_start.get(device)
trace_unwrappers[th_key] = CycleUnwrapper(batch_start)
unwrapped_cyc = trace_unwrappers[th_key].unwrap(raw_cyc)
all_traces.append({
'thread': thread,
'event': trace_match.group('event'),
'info': int(trace_match.group('info')),
'cycles': raw_cyc,
'unwrapped_cycles': unwrapped_cyc,
'state': trace_match.group('state'),
'device': device
})
f.close()
# Assign start/end cycles to all ops
for op in all_ops:
op['start_cycles'] = op['unwrapped_cycles_start']
op['end_cycles'] = op['start_cycles'] + op['cycles'] if op['start_cycles'] is not None else None
# Group ops by device
valid_ops_by_dev = defaultdict(list)
for op in all_ops:
if op['start_cycles'] is not None and op['end_cycles'] is not None:
valid_ops_by_dev[op['device']].append(op)
# Group trace events by device
traces_by_dev = defaultdict(list)
for e in all_traces:
if e['unwrapped_cycles'] is not None:
traces_by_dev[e['device']].append(e)
for device, dev_ops in valid_ops_by_dev.items():
opbatch_ops = [op for op in dev_ops if op['name'] == "OPBATCH"]
other_ops = [op for op in dev_ops if op['name'] != "OPBATCH"]
opbatch_ops.sort(key=lambda op: op['start_cycles'])
other_ops.sort(key=lambda op: op['start_cycles'])
opbatch_starts = [op['start_cycles'] for op in opbatch_ops]
other_starts = [op['start_cycles'] for op in other_ops]
dev_traces = traces_by_dev.get(device, [])
for e in dev_traces:
cyc = e['unwrapped_cycles']
# Map to OPBATCH
idx = bisect.bisect_right(opbatch_starts, cyc) - 1
if idx >= 0:
op = opbatch_ops[idx]
if op['start_cycles'] <= cyc <= op['end_cycles']:
op['trace_events'].append(e)
# Map to other ops
idx = bisect.bisect_right(other_starts, cyc) - 1
if idx >= 0:
op = other_ops[idx]
if op['start_cycles'] <= cyc <= op['end_cycles']:
op['trace_events'].append(e)
return all_ops
def print_bubbles_timeline(op):
op_name = op['name']
dims = op['dims']
types = op['types']
usec = op['usec']
cycles = op['cycles']
events = op['trace_events']
logger.info("=" * 100)
logger.info(f"{op_name} ({dims} : {types}) - {usec} usec {cycles} cycles")
logger.info("=" * 100)
if not events:
logger.info(" No trace events recorded.")
return
# Identify start and end cycles for this operator
op_start = op['start_cycles']
op_end = op['end_cycles']
if op_start is None or op_end is None:
logger.info(" Cannot analyze bubbles: missing start/end cycle counts.")
return
batch_duration = op_end - op_start
if batch_duration <= 0:
logger.info(" Cannot analyze bubbles: batch duration is 0.")
return
# Group events by (thread, track_type)
tracks = defaultdict(list)
for e in events:
t = e['thread']
is_dma = (normalize_event_name(e['event']) == 'DMA')
track_type = 'dma' if is_dma else 'compute'
tracks[(t, track_type)].append(e)
active_threads = sorted(list(set(t for (t, track_type) in tracks.keys())))
if not active_threads:
logger.info(" No active threads in trace.")
return
bubble_threshold = 10000 # 10k cycles
thread_stats = {}
for t in active_threads:
thread_stats[t] = {
'compute_idle_cycles': batch_duration,
'compute_idle_pct': 100.0,
'compute_bubbles': [],
'dma_idle_cycles': batch_duration,
'dma_idle_pct': 100.0,
'dma_bubbles': []
}
total_compute_idle_pct = 0.0
total_dma_idle_pct = 0.0
for t in active_threads:
for track_type in ['compute', 'dma']:
key = (t, track_type)
track_events = tracks.get(key, [])
if not track_events:
gaps = [(op_start, op_end)]
idle_cycles = batch_duration
else:
track_events = sorted(track_events, key=lambda e: e.get('unwrapped_cycles') or e['cycles'])
active_intervals = []
active_count = 0
curr_start = None
for e in track_events:
cyc = e.get('unwrapped_cycles') or e['cycles']
cyc = max(op_start, min(op_end, cyc))
state = e['state']
if state == 'start':
if active_count == 0:
curr_start = cyc
active_count += 1
elif state == 'stop':
if active_count > 0:
active_count -= 1
if active_count == 0:
active_intervals.append((curr_start, cyc))
else:
active_intervals.append((op_start, cyc))
if active_count > 0 and curr_start is not None:
active_intervals.append((curr_start, op_end))
# Merge intervals
active_intervals.sort(key=lambda x: x[0])
merged_intervals = []
for start, end in active_intervals:
if not merged_intervals:
merged_intervals.append([start, end])
else:
last_start, last_end = merged_intervals[-1]
if start <= last_end:
merged_intervals[-1][1] = max(last_end, end)
else:
merged_intervals.append([start, end])
# Calculate gaps
gaps = []
curr_time = op_start
for start, end in merged_intervals:
if start > curr_time:
gaps.append((curr_time, start))
curr_time = max(curr_time, end)
if curr_time < op_end:
gaps.append((curr_time, op_end))
idle_cycles = sum(end - start for start, end in gaps)
idle_pct = (idle_cycles / batch_duration) * 100.0
bubbles = []
for start, end in gaps:
dur = end - start
if dur >= bubble_threshold:
bubbles.append((start, end, dur))
if track_type == 'compute':
thread_stats[t]['compute_idle_cycles'] = idle_cycles
thread_stats[t]['compute_idle_pct'] = idle_pct
thread_stats[t]['compute_bubbles'] = bubbles
total_compute_idle_pct += idle_pct
else:
thread_stats[t]['dma_idle_cycles'] = idle_cycles
thread_stats[t]['dma_idle_pct'] = idle_pct
thread_stats[t]['dma_bubbles'] = bubbles
total_dma_idle_pct += idle_pct
avg_compute_idle = total_compute_idle_pct / len(active_threads)
avg_dma_idle = total_dma_idle_pct / len(active_threads)
logger.info(" Combined Idle Statistics:")
logger.info(f" Active Threads : {', '.join(str(t) for t in active_threads)}")
logger.info(f" Avg Thread Compute IDLE : {avg_compute_idle:.1f}%")
logger.info(f" Avg Thread DMA IDLE : {avg_dma_idle:.1f}%")
logger.info("-" * 100)
logger.info(" Per-Thread Idle Analysis:")
for t in active_threads:
stats = thread_stats[t]
thread_name = f"Thread {t:<2} (HVX)" if t != 10 else "Thread 10 (HMX)"
logger.info(f" {thread_name} -> Compute Idle: {stats['compute_idle_pct']:.1f}% | DMA Idle: {stats['dma_idle_pct']:.1f}%")
all_bubbles = []
for t in active_threads:
stats = thread_stats[t]
assert isinstance(stats['dma_bubbles'], Iterable)
assert isinstance(stats['compute_bubbles'], Iterable)
for start, end, dur in stats['compute_bubbles']:
pct = (dur / batch_duration) * 100.0
all_bubbles.append((dur, f"Thread {t} Compute: bubble of {dur} cycles ({pct:.1f}%) at {start - op_start} to {end - op_start}"))
for start, end, dur in stats['dma_bubbles']:
pct = (dur / batch_duration) * 100.0
all_bubbles.append((dur, f"Thread {t} DMA : bubble of {dur} cycles ({pct:.1f}%) at {start - op_start} to {end - op_start}"))
if all_bubbles:
logger.info("-" * 100)
logger.info(f" Significant Bubbles (>= {bubble_threshold} cycles):")
all_bubbles.sort(key=lambda x: x[0], reverse=True)
for dur, desc in all_bubbles[:15]:
logger.info(f" {desc}")
else:
logger.info("-" * 100)
logger.info(f" No significant bubbles detected (all idle gaps < {bubble_threshold} cycles).")
def print_ascii_summary(op_name, dims, types, usec, cycles, events):
logger.info("=" * 100)
logger.info(f"{op_name} ({dims} : {types}) - {usec} usec {cycles} cycles")
logger.info("=" * 100)
events = sorted(events, key=lambda e: e['cycles'])
if not events:
logger.info(" No trace events recorded.")
return
active_starts = {}
thread_totals = defaultdict(lambda: defaultdict(int))
for e in events:
t = e['thread']
evt = e['event']
info = e['info']
cyc = e['cycles']
state = e['state']
key = (t, evt, info)
if state == 'start':
active_starts[key] = cyc
elif state == 'stop':
if key in active_starts:
start_cyc = active_starts[key]
del active_starts[key]
if cyc >= start_cyc:
dur = cyc - start_cyc
else:
dur = (cyc + 0x100000000) - start_cyc
norm_evt = normalize_event_name(evt)
thread_totals[t][norm_evt] += dur
for t in sorted(thread_totals.keys()):
thread_name = f"Thread {t} (HVX)" if t != 10 else "Thread 10 (HMX)"
sorted_evts = sorted(thread_totals[t].items(), key=lambda item: item[0])
evt_strs = []
for evt, dur in sorted_evts:
pct = (dur / cycles * 100) if cycles > 0 else 0
evt_strs.append(f"{evt} {dur} ({pct:.1f}%)")
logger.info(f" {thread_name:<16}: " + " | ".join(evt_strs))
def generate_report(ops, top_n, width_overrides, sort_col, pmu_name=None):
if not ops:
logger.info("No valid records found.")
return
grouped = defaultdict(list)
for op in ops:
key = (op['name'], op['dims'], op['types'])
grouped[key].append(op)
group_stats = []
for (name, dims, types), group_ops in grouped.items():
usecs = [o['usec'] for o in group_ops]
cycles = [o['cycles'] for o in group_ops]
pmu_vals = [o['pmu_val'] for o in group_ops if o['pmu_val'] is not None]
avg_usec_val = statistics.mean(usecs)
count_val = len(group_ops)
tot_usec_val = avg_usec_val * count_val
group_stats.append({
'op': name,
'dims': dims,
'dtypes': types,
'count': str(count_val),
'max_usec': str(max(usecs)),
'avg_usec': f"{avg_usec_val:.2f}",
'tot_usec': f"{tot_usec_val:.2f}",
'max_cycles': str(max(cycles)),
'avg_cycles': f"{statistics.mean(cycles):.2f}",
'max_pmu': str(max(pmu_vals)) if pmu_vals else "0",
'avg_pmu': f"{statistics.mean(pmu_vals):.2f}" if pmu_vals else "0.00",
# Numeric values for accurate sorting
'_sort_count': count_val,
'_sort_max_usec': max(usecs),
'_sort_avg_usec': avg_usec_val,
'_sort_tot_usec': tot_usec_val,
'_sort_max_cycles': max(cycles),
'_sort_avg_cycles': statistics.mean(cycles),
'_sort_max_pmu': max(pmu_vals) if pmu_vals else 0,
'_sort_avg_pmu': statistics.mean(pmu_vals) if pmu_vals else 0
})
# Sorting logic
actual_sort_key = COL_MAP[sort_col][2]
is_numeric = actual_sort_key.startswith("_") or actual_sort_key == "count"
sorted_groups = sorted(group_stats, key=lambda x: x[actual_sort_key], reverse=is_numeric)[:top_n]
# Define initial column order
active_cols = ["op", "dims", "dtypes"]
if pmu_name:
active_cols += ["max-pmu", "avg-pmu"]
active_cols += ["tot-usec", "avg-usec", "avg-cycles", "max-usec", "max-cycles", "count"]
final_headers, final_keys, final_widths = [], [], []
for col_name in active_cols:
data_key, header_text, _ = COL_MAP[col_name]
if "pmu" in col_name and pmu_name:
header_text = header_text.replace("PMU", pmu_name)
natural_width = max([len(str(row[data_key])) for row in sorted_groups] + [len(header_text)])
target_width = width_overrides.get(col_name, natural_width)
if target_width == 0:
continue
final_headers.append(header_text)
final_keys.append(data_key)
final_widths.append(target_width)
# Print Report
logger.info(f"\n# Profile Report (Top {top_n} Ops sorted by {sort_col})\n")
header_line = "| " + " | ".join(f"{h:<{final_widths[i]}}" for i, h in enumerate(final_headers)) + " |"
sep_line = "| " + " | ".join("-" * final_widths[i] for i in range(len(final_headers))) + " |"
logger.info(header_line)
logger.info(sep_line)
for group in sorted_groups:
row_vals = []
for i, key in enumerate(final_keys):
val = str(group[key])
if len(val) > final_widths[i]:
val = val[:final_widths[i] - 3] + "..."
row_vals.append(f"{val:<{final_widths[i]}}")
logger.info("| " + " | ".join(row_vals) + " |")
def has_perf_print(file_path):
if file_path == "-":
return True
with open(file_path, 'r', encoding='utf-8', errors='ignore') as f:
return any("common_perf_print" in line for line in f)
def pct_delta(a, b):
return (b - a) * 100.0 / a if a else None
def fmt_pct(p):
return f"{p:+.2f}%" if p is not None else "n/a"
def print_table(title, cols, rows, width_overrides):
# cols: list of (cli name, header); rows: list of dicts keyed by cli name
logger.info(f"\n# {title}\n")
if not rows:
logger.info("None.")
return
headers, keys, widths = [], [], []
for key, header in cols:
natural_width = max([len(str(row[key])) for row in rows] + [len(header)])
target_width = width_overrides.get(key, natural_width)
if target_width == 0:
continue
headers.append(header)
keys.append(key)
widths.append(target_width)
logger.info("| " + " | ".join(f"{h:<{widths[i]}}" for i, h in enumerate(headers)) + " |")
logger.info("| " + " | ".join("-" * widths[i] for i in range(len(headers))) + " |")
for row in rows:
row_vals = []
for i, key in enumerate(keys):
val = str(row[key])
if len(val) > widths[i]:
val = val[:widths[i] - 3] + "..."
row_vals.append(f"{val:<{widths[i]}}")
logger.info("| " + " | ".join(row_vals) + " |")
def generate_compare_report(ops_a, ops_b, file_a, file_b, top_n, width_overrides, min_delta):
def summarize(ops):
batches = [o for o in ops if o['name'] == "OPBATCH"]
others = [o for o in ops if o['name'] != "OPBATCH"]
mhz = [o['mhz'] for o in others if o['mhz'] is not None]
return {
'n_ops': len(others),
'tot_usec': sum(o['usec'] for o in others),
'n_batch': len(batches),
'batch_usec': sum(o['usec'] for o in batches),
'avg_mhz': statistics.mean(mhz) if mhz else 0.0,
}
sa, sb = summarize(ops_a), summarize(ops_b)
logger.info("\n# Compare Summary (OPBATCH excluded from op totals)\n")
logger.info(f" A (baseline) : {file_a}")
logger.info(f" B (new) : {file_b}")
logger.info(" Deltas are B - A: negative means B is faster\n")
logger.info(f" Ops : A {sa['n_ops']} B {sb['n_ops']}")
logger.info(f" Tot op usec : A {sa['tot_usec']} B {sb['tot_usec']} delta {sb['tot_usec'] - sa['tot_usec']:+d} ({fmt_pct(pct_delta(sa['tot_usec'], sb['tot_usec']))})")
logger.info(f" OPBATCH : A {sa['n_batch']} / {sa['batch_usec']} usec B {sb['n_batch']} / {sb['batch_usec']} usec")
logger.info(f" Avg op mhz : A {sa['avg_mhz']:.1f} B {sb['avg_mhz']:.1f}")
if sa['n_batch'] != sb['n_batch']:
logger.warning(f" WARNING: OPBATCH counts differ (A {sa['n_batch']}, B {sb['n_batch']}), one log may be incomplete")
for label, path in (("A", file_a), ("B", file_b)):
if not has_perf_print(path):
logger.warning(f" WARNING: log {label} has no common_perf_print line, run may be incomplete")
# Per-op-name rollup
by_name = defaultdict(lambda: [0, 0, 0, 0])
for idx, ops in ((0, ops_a), (1, ops_b)):
for op in ops:
if op['name'] == "OPBATCH":
continue
by_name[op['name']][idx] += 1
by_name[op['name']][idx + 2] += op['usec']
rows = []
for name, (cnt_a, cnt_b, tot_a, tot_b) in by_name.items():
p = pct_delta(tot_a, tot_b)
if min_delta and p is not None and abs(p) < min_delta:
continue
rows.append({'op': name, 'count-a': cnt_a, 'count-b': cnt_b, 'tot-usec-a': tot_a, 'tot-usec-b': tot_b,
'delta-usec': f"{tot_b - tot_a:+d}", 'delta-pct': fmt_pct(p), '_sort': abs(tot_b - tot_a)})
rows.sort(key=lambda r: r['_sort'], reverse=True)
print_table("Per-Op Rollup (delta = B - A, sorted by abs delta usec)", [
("op", "Op"), ("count-a", "Count A"), ("count-b", "Count B"), ("tot-usec-a", "Tot usec A"),
("tot-usec-b", "Tot usec B"), ("delta-usec", "Delta usec"), ("delta-pct", "Delta %")], rows, width_overrides)
# Per-group compare keyed on (op, dims, dtypes)
def group(ops):
grouped = defaultdict(list)
for op in ops:
if op['name'] != "OPBATCH":
grouped[(op['name'], op['dims'], op['types'])].append(op['usec'])
return grouped
ga, gb = group(ops_a), group(ops_b)
rows = []
for key in ga.keys() & gb.keys():
ua, ub = ga[key], gb[key]
tot_a, tot_b = sum(ua), sum(ub)
avg_a, avg_b = tot_a / len(ua), tot_b / len(ub)
p = pct_delta(avg_a, avg_b)
if min_delta and p is not None and abs(p) < min_delta:
continue
rows.append({'op': key[0], 'dims': key[1], 'dtypes': key[2], 'count-a': len(ua), 'count-b': len(ub),
'avg-usec-a': f"{avg_a:.2f}", 'avg-usec-b': f"{avg_b:.2f}", 'delta-pct': fmt_pct(p),
'tot-usec-a': tot_a, 'tot-usec-b': tot_b, 'delta-usec': f"{tot_b - tot_a:+d}", '_sort': abs(tot_b - tot_a)})
rows.sort(key=lambda r: r['_sort'], reverse=True)
print_table(f"Per-Group Compare (delta = B - A, top {top_n} sorted by abs delta usec)", [
("op", "Op"), ("dims", "Dims"), ("dtypes", "DTypes"), ("count-a", "Count A"), ("count-b", "Count B"),
("avg-usec-a", "Avg usec A"), ("avg-usec-b", "Avg usec B"), ("delta-pct", "Delta %"),
("tot-usec-a", "Tot usec A"), ("tot-usec-b", "Tot usec B"), ("delta-usec", "Delta usec")], rows[:top_n], width_overrides)
# Groups present in only one log
for label, g, other in (("A", ga, gb), ("B", gb, ga)):
rows = [{'op': k[0], 'dims': k[1], 'dtypes': k[2], 'count': len(v), 'tot-usec': sum(v)} for k, v in g.items() if k not in other]
rows.sort(key=lambda r: r['tot-usec'], reverse=True)
print_table(f"Groups only in {label} (Top {top_n} sorted by tot usec)", [
("op", "Op"), ("dims", "Dims"), ("dtypes", "DTypes"), ("count", "Count"), ("tot-usec", "Tot usec")], rows[:top_n], width_overrides)
def filter_ops(ops, args):
if args.filter:
try:
filter_re = re.compile(args.filter)
except re.error as e:
logger.error(f"Invalid regex filter: {e}")
sys.exit(1)
ops = [op for op in ops if filter_re.search(op['op_text'])]
if args.head is not None or args.tail is not None:
ops_by_dev = defaultdict(list)
for op in ops:
ops_by_dev[op['device']].append(op)
filtered_ops = []
for dev in sorted(ops_by_dev.keys()):
dev_ops = ops_by_dev[dev]
if args.head is not None:
dev_ops = dev_ops[:args.head]
elif args.tail is not None:
dev_ops = dev_ops[-args.tail:]
filtered_ops.extend(dev_ops)
ops = filtered_ops
return ops
def main():
parser = argparse.ArgumentParser(description="Post-process Op profile info.")
parser.add_argument("logfile")
parser.add_argument("-n", "--top", type=int, default=100)
parser.add_argument("--sort", type=str, default="tot-usec", choices=list(COL_MAP.keys()))
parser.add_argument("--pmu-index", type=int)
parser.add_argument("--pmu-name", type=str)
parser.add_argument("--width", action='append', default=['dims:40'], help="Override column width, e.g. --width dims:50")
parser.add_argument("--timeline", type=str, nargs='?', const='summary', choices=["summary", "bubbles"],
help="Output ASCII art event summary or thread idle bubble analysis (default: summary)")
parser.add_argument("--filter", type=str, help="Regex filter matching against the original profile-op line")
parser.add_argument("--device", type=str, help="Device to filter by (e.g. HTP0, HTP0:0) or 'split' to generate separate reports per device")
parser.add_argument("--compare", type=str, metavar="LOGFILE", help="Compare against another log (logfile is baseline A, this is B)")
parser.add_argument("--min-delta", type=float, default=0, metavar="PCT", help="In compare mode hide rows with abs(Delta %%) below PCT")
group = parser.add_mutually_exclusive_group()
group.add_argument("--head", type=int, help="Limit to first N ops")
group.add_argument("--tail", type=int, help="Limit to last N ops")
args = parser.parse_args()
logging.basicConfig(level=logging.INFO, format="%(message)s", stream=sys.stdout)
if "pmu" in args.sort and args.pmu_index is None:
logger.error(f"Cannot sort by '{args.sort}' without --pmu-index.")
sys.exit(1)
if args.compare and (args.timeline or args.device == "split"):
logger.error("--compare cannot be used with --timeline or --device split.")
sys.exit(1)
overrides = {}
if args.width:
for w in args.width:
try:
name, val = w.split(':')
overrides[name.lower()] = int(val)
except ValueError:
logger.warning(f"Invalid width format '{w}'")
final_pmu_name = (args.pmu_name or f"#{args.pmu_index}") if args.pmu_index is not None else None
op_filter_re = None
if args.filter:
try:
op_filter_re = re.compile(args.filter)
except re.error as e:
logger.error(f"Invalid regex filter: {e}")
sys.exit(1)
limit = args.head if args.head is not None else None
device_filter = args.device if (args.device and args.device != "split") else None
ops = parse_log(args.logfile, pmu_index=args.pmu_index, limit=limit, device_filter=device_filter, op_filter_re=op_filter_re)
if args.device and args.device != "split":
ops = [op for op in ops if device_matches(op['device'], args.device)]
if args.compare:
ops_b = parse_log(args.compare, pmu_index=args.pmu_index, limit=limit, device_filter=device_filter, op_filter_re=op_filter_re)
if device_filter:
ops_b = [op for op in ops_b if device_matches(op['device'], device_filter)]
generate_compare_report(filter_ops(ops, args), filter_ops(ops_b, args), args.logfile, args.compare, args.top, overrides, args.min_delta)
return
if args.device == "split":
unique_devices = sorted(list(set(op['device'] for op in ops)))
for dev in unique_devices:
dev_ops = [op for op in ops if device_matches(op['device'], dev)]
if args.filter:
try:
filter_re = re.compile(args.filter)
except re.error as e:
logger.error(f"Invalid regex filter: {e}")
sys.exit(1)
dev_ops = [op for op in dev_ops if filter_re.search(op['op_text'])]
if args.head is not None:
dev_ops = dev_ops[:args.head]
elif args.tail is not None:
dev_ops = dev_ops[-args.tail:]
logger.info("\n=========================================")
logger.info(f" Device: {dev}")
logger.info("=========================================")
if args.timeline:
for op in dev_ops:
if args.timeline == "summary":
print_ascii_summary(op['name'], op['dims'], op['types'], op['usec'], op['cycles'], op['trace_events'])
elif args.timeline == "bubbles":
print_bubbles_timeline(op)
else:
generate_report(dev_ops, args.top, overrides, args.sort, pmu_name=final_pmu_name)
else:
ops = filter_ops(ops, args)
if args.timeline:
for op in ops:
if args.timeline == "summary":
print_ascii_summary(op['name'], op['dims'], op['types'], op['usec'], op['cycles'], op['trace_events'])
elif args.timeline == "bubbles":
print_bubbles_timeline(op)
else:
generate_report(ops, args.top, overrides, args.sort, pmu_name=final_pmu_name)
if __name__ == "__main__":
main()
+819
View File
@@ -0,0 +1,819 @@
#!/usr/bin/env python3
import sys
import os
import re
import argparse
import statistics
import logging
import bisect
from typing import Any, Dict, List, Optional
from collections import defaultdict
logging.basicConfig(level=logging.INFO, format="%(message)s", stream=sys.stdout)
logger = logging.getLogger("ggml-hexagon-trace")
op_pattern = re.compile(
r"profile-op\s+(?P<op_name>[A-Z_0-9+]+):\s+.*?\s+:\s+(?P<dims>[\d:x\s\->!]+)\s+:\s+(?P<types>[a-z\d_\s\->x]+)\s+:\s+(?P<strides>[\d:x\s\->!]+?)\s+:\s+(?:(?P<params>.*?)\s+:\s+)?(?:op-)?usec\s+(?P<usec>\d+)\s+(?:op-)?cycles\s+(?P<cycles>\d+)(?:\s+start\s+(?P<start>\d+))?(?:\s+mhz\s+(?P<mhz>[\d.]+))?(?:\s+pmu\s+\[(?P<pmu>[\d,\s]+)\])?(?:\s+evt\s+\[(?P<evt>[\d,\s]+)\])?"
)
trace_pattern = re.compile(
r"trace-evt\s+(?P<event>[A-Z_0-9\-]+):\s+thread\s+(?P<thread>\d+)\s+info\s+(?P<info>\d+)\s+(?P<state>start|stop)\s+(?P<cycles>\d+)"
)
device_pattern = re.compile(r"\b(HTP\d+(?::\d+)?)\s+(?:profile-op|trace-evt)\b")
def extract_device(line):
m = device_pattern.search(line)
if m:
return m.group(1)
return "HTP0"
def device_matches(record_device, target_device):
targets = [t.strip() for t in target_device.split(',')]
for target in targets:
if record_device == target:
return True
if record_device.startswith(target + ":"):
return True
return False
def get_split_output_path(base_path, device_name):
safe_device = device_name.replace(':', '_')
root, ext = os.path.splitext(base_path)
return f"{root}-{safe_device}{ext}"
def normalize_event_name(evt_type, info=0):
if evt_type == "HVX_COMP":
return "V-COMP"
if evt_type == "HMX_COMP":
return "M-COMP"
name = evt_type
if name.startswith("HVX_") or name.startswith("HMX_"):
name = name[4:]
return name.replace("_", "-")
class CycleUnwrapper:
def __init__(self, initial_val=None):
if initial_val is not None:
self.last_raw = initial_val & 0xFFFFFFFF
self.high_part = initial_val & 0xFFFFFFFF00000000
else:
self.last_raw = None
self.high_part = 0
def unwrap(self, raw):
if self.last_raw is None:
self.last_raw = raw
return raw
diff = raw - self.last_raw
if diff < -0x80000000:
self.high_part += 0x100000000
elif diff > 0x80000000:
self.high_part -= 0x100000000
self.last_raw = raw
return raw + self.high_part
class DeviceTimeMapper:
def __init__(self, dev, ops):
self.dev = dev
self.batches = []
for op in ops:
if op.get('device') == dev and op.get('name') == 'OPBATCH' and op.get('unwrapped_cycles_start') is not None:
cycles = op.get('cycles', 0)
usec = op.get('usec', 0)
start_cyc = op['unwrapped_cycles_start']
freq = (cycles / usec) if usec > 0 and cycles > 0 else 1000.0
if freq <= 0:
freq = 1000.0
self.batches.append({
'start_cycles': start_cyc,
'cycles': cycles,
'end_cycles': start_cyc + cycles,
'usec': usec,
'dur_ns': usec * 1000,
'freq_mhz': freq,
})
self.batches.sort(key=lambda b: b['start_cycles'])
for i, b in enumerate(self.batches):
if i == 0:
b['start_time_ns'] = 0
else:
prev = self.batches[i - 1]
idle_cyc = max(0, b['start_cycles'] - prev['end_cycles'])
idle_ns = int(round((idle_cyc / prev['freq_mhz']) * 1000))
b['start_time_ns'] = prev['start_time_ns'] + prev['dur_ns'] + idle_ns
self.batch_starts = [b['start_cycles'] for b in self.batches]
valid_starts = [op['unwrapped_cycles_start'] for op in ops if op.get('device') == dev and op.get('unwrapped_cycles_start') is not None]
self.min_cyc = min(valid_starts) if valid_starts else 0
if self.batches:
self.default_freq = self.batches[0]['freq_mhz']
else:
freqs = [op['cycles'] / op['usec'] for op in ops if op.get('device') == dev and op.get('usec', 0) > 0 and op.get('cycles', 0) > 0]
self.default_freq = statistics.mean(freqs) if freqs else 1000.0
def get_batch(self, cyc):
if not self.batches:
return None
idx = bisect.bisect_right(self.batch_starts, cyc) - 1
if idx >= 0:
return self.batches[idx]
return self.batches[0]
def get_freq(self, cyc=None):
if cyc is not None:
b = self.get_batch(cyc)
if b is not None:
return b['freq_mhz']
return self.default_freq
def cycle_to_ns(self, cyc):
if cyc is None:
return 0
b = self.get_batch(cyc)
if b is not None:
return b['start_time_ns'] + int(round(((cyc - b['start_cycles']) / b['freq_mhz']) * 1000))
return int(round(((cyc - self.min_cyc) / self.default_freq) * 1000))
def dur_cycles_to_ns(self, cyc_start, cyc_dur):
if cyc_dur is None:
return 0
freq = self.get_freq(cyc_start)
return int(round((cyc_dur / freq) * 1000))
def parse_log(file_path, limit=None, device_filter=None, op_filter_re=None):
try:
if file_path != "-":
f = open(file_path, 'r', encoding='utf-8', errors='ignore')
else:
f = os.fdopen(0, 'r', encoding='utf-8', errors='ignore')
except FileNotFoundError:
logger.error(f"file '{file_path}' not found.")
sys.exit(1)
all_ops: List[Dict[str, Any]] = []
all_traces: List[Dict[str, Any]] = []
current_op: Optional[Dict[str, Any]] = None
ops_count_per_device = {}
if device_filter is not None:
for target in device_filter.split(','):
ops_count_per_device[target.strip()] = 0
limit_reached = False
unwrappers = {}
last_batch_start = {}
trace_unwrappers = {}
line_idx = 0
for line in f:
line_idx += 1
if "profile-op" not in line and "trace-evt" not in line:
continue
device = extract_device(line)
idx = line.find("profile-op")
if idx != -1 and "|" in line[idx:]:
parts = [p.strip() for p in line[idx:].split("|")]
prefix = parts[0]
prefix_match = re.search(r"profile-op\s+(?P<op_name>[A-Z_0-9+]+)", prefix)
if not prefix_match:
continue
names = parts[1]
if len(parts) == 7:
dims, types, strides, params, timings = parts[2], parts[3], parts[4], parts[5], parts[6]
elif len(parts) == 6:
dims, types, strides, params, timings = parts[2], parts[3], parts[4], "", parts[5]
else:
continue
timing_match = re.search(
r"(?:op-)?usec\s+(?P<usec>\d+)\s+(?:op-)?cycles\s+(?P<cycles>\d+)(?:\s+start\s+(?P<start>\d+))?(?:\s+mhz\s+(?P<mhz>[\d.]+))?(?:\s+pmu\s+\[(?P<pmu>[\d,\s]+)\])?(?:\s+evt\s+\[(?P<evt>[\d,\s]+)\])?",
timings
)
if not timing_match:
continue
op_match = timing_match
op_name = prefix_match.group("op_name")
else:
op_match = op_pattern.search(line)
if op_match:
op_name = op_match.group('op_name')
names = ""
dims = op_match.group('dims').strip() if op_match.group('dims') else ''
types = op_match.group('types').strip() if op_match.group('types') else ''
strides = op_match.group('strides').strip() if op_match.group('strides') else ''
params = op_match.group('params').strip() if ('params' in op_match.groupdict() and op_match.group('params')) else ''
else:
op_match = None
if op_match:
cycles_start_raw = op_match.group('start')
unwrapped_cycles_start = None
if op_name == "OPBATCH":
if cycles_start_raw:
unwrapped_cycles_start = int(cycles_start_raw)
unwrappers[device] = CycleUnwrapper(unwrapped_cycles_start)
last_batch_start[device] = unwrapped_cycles_start
for k in list(trace_unwrappers.keys()):
if k[0] == device:
del trace_unwrappers[k]
else:
if cycles_start_raw:
device_unwrapper = unwrappers.get(device)
if device_unwrapper is not None:
unwrapped_cycles_start = device_unwrapper.unwrap(int(cycles_start_raw))
op_text = re.sub(r"^profile-op\s+", "", line[idx:]).strip() if idx != -1 else line.strip()
evt_str = None
if types.startswith("evt-cnt "):
evt_str = types[8:].strip()
current_op = {
'name': op_name,
'names': names,
'dims': dims,
'types': types,
'strides': strides,
'params': params,
'evt': evt_str,
'op_text': op_text,
'usec': int(op_match.group('usec')),
'cycles': int(op_match.group('cycles')),
'cycles_start': int(cycles_start_raw) if cycles_start_raw else None,
'unwrapped_cycles_start': unwrapped_cycles_start,
'trace_events': [],
'line_num': line_idx,
'device': device
}
all_ops.append(current_op)
# Check if matching early exit criteria
matched = False
matched_target = None
if device_filter is not None:
targets = [t.strip() for t in device_filter.split(',')]
for target in targets:
if device == target or device.startswith(target + ":"):
matched = True
matched_target = target
break
else:
matched = True
matched_target = device
if op_filter_re is not None and not op_filter_re.search(op_text):
matched = False
if matched:
if matched_target not in ops_count_per_device:
ops_count_per_device[matched_target] = 0
ops_count_per_device[matched_target] += 1
if limit is not None and len(ops_count_per_device) > 0 and all(count >= limit for count in ops_count_per_device.values()):
limit_reached = True
if limit_reached and op_name == "OPBATCH":
break
continue
trace_match = trace_pattern.search(line)
if trace_match:
thread = int(trace_match.group('thread'))
raw_cyc = int(trace_match.group('cycles'))
unwrapped_cyc = None
th_key = (device, thread)
if th_key not in trace_unwrappers:
batch_start = last_batch_start.get(device)
trace_unwrappers[th_key] = CycleUnwrapper(batch_start)
unwrapped_cyc = trace_unwrappers[th_key].unwrap(raw_cyc)
all_traces.append({
'thread': thread,
'event': trace_match.group('event'),
'info': int(trace_match.group('info')),
'cycles': raw_cyc,
'unwrapped_cycles': unwrapped_cyc,
'state': trace_match.group('state'),
'line_num': line_idx,
'device': device
})
f.close()
return all_ops, all_traces
# --- Simple protobuf encoder ---
def write_varint(val):
if val < 0:
val = (1 << 64) + val
res = bytearray()
while True:
towrite = val & 0x7f
val >>= 7
if val > 0:
res.append(towrite | 0x80)
else:
res.append(towrite)
break
return bytes(res)
def pb_field(num, wire, data):
return write_varint((num << 3) | wire) + data
def pb_varint(num, val):
return pb_field(num, 0, write_varint(val))
def pb_length_delimited(num, data):
return pb_field(num, 2, write_varint(len(data)) + data)
def pb_string(num, text):
return pb_length_delimited(num, text.encode('utf-8'))
# Message Encoders
def make_process_descriptor(pid, name):
return pb_varint(1, pid) + pb_string(6, name)
def make_thread_descriptor(pid, tid, name, sort_index=None):
payload = pb_varint(1, pid) + pb_varint(2, tid) + pb_string(5, name)
if sort_index is not None:
payload += pb_varint(3, sort_index)
return payload
def make_track_descriptor(uuid, name=None, parent_uuid=None, thread=None, process=None, sibling_merge_behavior=None, child_ordering=None, sibling_order_rank=None):
payload = pb_varint(1, uuid)
if name is not None:
payload += pb_string(2, name)
if parent_uuid is not None:
payload += pb_varint(5, parent_uuid)
if process is not None:
payload += pb_length_delimited(3, process)
if thread is not None:
payload += pb_length_delimited(4, thread)
if sibling_merge_behavior is not None:
payload += pb_varint(15, sibling_merge_behavior)
if child_ordering is not None:
payload += pb_varint(11, child_ordering)
if sibling_order_rank is not None:
payload += pb_varint(12, sibling_order_rank)
return payload
def make_debug_annotation(name, string_val=None, int_val=None):
payload = pb_string(10, name)
if string_val is not None:
payload += pb_string(6, string_val)
elif int_val is not None:
payload += pb_varint(4, int_val)
return payload
def make_track_event(event_type, track_uuid, name=None, category=None, debug_annotations=None):
payload = pb_varint(9, event_type)
payload += pb_varint(11, track_uuid)
if name is not None:
payload += pb_string(23, name)
if category is not None:
payload += pb_string(22, category)
if debug_annotations is not None:
for da in debug_annotations:
payload += pb_length_delimited(4, da)
return payload
def make_trace_packet(timestamp, track_event=None, track_descriptor=None, seq_id=1):
payload = pb_varint(8, timestamp)
payload += pb_varint(10, seq_id)
if track_event is not None:
payload += pb_length_delimited(11, track_event)
if track_descriptor is not None:
payload += pb_length_delimited(60, track_descriptor)
return payload
def write_trace_packet_to_file(f, packet_bytes):
# Write as field 1 of top-level Trace message
f.write(pb_length_delimited(1, packet_bytes))
# --- End Protobuf Encoder ---
def generate_perfetto_trace(filtered_ops, trace_events, output_path):
if not filtered_ops:
logger.warning("No operators found after filtering.")
return
# Assign start and end cycles to each operator
for op in filtered_ops:
op['start_cycles'] = op['unwrapped_cycles_start']
op['end_cycles'] = op['start_cycles'] + op['cycles'] if op['start_cycles'] is not None else None
# Get list of unique devices present in the operations
unique_devices = sorted(list(set(op['device'] for op in filtered_ops)))
device_to_idx = {dev: idx for idx, dev in enumerate(unique_devices)}
time_mappers = {dev: DeviceTimeMapper(dev, filtered_ops) for dev in unique_devices}
# Process events
completed_events = []
if trace_events:
trace_events = sorted(trace_events, key=lambda e: e['unwrapped_cycles'])
one_usec_cycles = {}
for dev in unique_devices:
one_usec_cycles[dev] = max(time_mappers[dev].get_freq(), 1.0)
active_starts = {}
for e in trace_events:
t = e['thread']
evt = e['event']
info = e['info']
state = e['state']
cyc = e['unwrapped_cycles']
dev = e['device']
key = (dev, t, evt, info)
if state == 'start':
# Handle missing stop (start followed by another start)
if key in active_starts:
prev_e = active_starts[key]
completed_events.append({
'thread': t,
'event': evt,
'info': info,
'start_cyc': prev_e['unwrapped_cycles'],
'end_cyc': prev_e['unwrapped_cycles'] + one_usec_cycles.get(dev, 1000.0),
'line_num': prev_e.get('line_num'),
'missing_stop': True,
'device': dev
})
active_starts[key] = e
elif state == 'stop':
if key in active_starts:
prev_e = active_starts[key]
del active_starts[key]
completed_events.append({
'thread': t,
'event': evt,
'info': info,
'start_cyc': prev_e['unwrapped_cycles'],
'end_cyc': cyc,
'line_num': prev_e.get('line_num'),
'device': dev
})
else:
# Handle missing start (stop without start)
completed_events.append({
'thread': t,
'event': evt,
'info': info,
'start_cyc': cyc - one_usec_cycles.get(dev, 1000.0),
'end_cyc': cyc,
'line_num': e.get('line_num'),
'missing_start': True,
'device': dev
})
# Clear remaining unmatched starts
for key, prev_e in active_starts.items():
dev, t, evt, info = key
completed_events.append({
'thread': t,
'event': evt,
'info': info,
'start_cyc': prev_e['unwrapped_cycles'],
'end_cyc': prev_e['unwrapped_cycles'] + one_usec_cycles.get(dev, 1000.0),
'line_num': prev_e.get('line_num'),
'missing_stop': True,
'device': dev
})
completed_events.sort(key=lambda e: e['start_cyc'])
# Convert event times to nanoseconds using per-device / per-batch time mapper
for e in completed_events:
dev = e['device']
tm = time_mappers[dev]
e['ts_ns'] = tm.cycle_to_ns(e['start_cyc'])
dur_ns = tm.dur_cycles_to_ns(e['start_cyc'], e['end_cyc'] - e['start_cyc'])
e['dur_ns'] = max(dur_ns, 1)
# Allocate slots (sub-tracks) to prevent overlaps on same virtual track
active_slots = defaultdict(list)
for e in completed_events:
t = e['thread']
evt = e['event']
ts = e['ts_ns']
dur = e['dur_ns']
dev = e['device']
norm_evt = normalize_event_name(evt, e['info'])
if norm_evt == "DMA":
track_key = (dev, t, "DMA")
elif t == 10:
track_key = (dev, t, "HMX")
else:
track_key = (dev, t, "HVX")
slots = active_slots[track_key]
allocated_slot = -1
for idx, slot_end_ns in enumerate(slots):
if ts >= slot_end_ns:
slots[idx] = ts + dur
allocated_slot = idx
break
if allocated_slot == -1:
slots.append(ts + dur)
allocated_slot = len(slots) - 1
e['slot'] = allocated_slot
# Generate Track IDs and track definitions
used_tracks = {}
for e in completed_events:
t = e['thread']
evt = e['event']
slot = e['slot']
dev = e['device']
norm_evt = normalize_event_name(evt, e['info'])
if norm_evt == "DMA":
track_evt = "DMA"
evt_id = 1
elif t == 10:
track_evt = "HMX"
evt_id = 3
else:
track_evt = "HVX"
evt_id = 2
t_sort = 1 if t == 10 else t + 2
dev_idx = device_to_idx[dev]
# Unique UUID for each sub-track
if t == 10:
uuid = dev_idx * 10000000 + 20 # HMX thread track UUID
else:
uuid = int(dev_idx * 10000000 + t_sort * 1000000 + evt_id * 1000 + slot)
e['uuid'] = uuid
used_tracks[uuid] = (dev, t, track_evt, slot)
with open(output_path, "wb") as f:
for dev in unique_devices:
dev_idx = device_to_idx[dev]
pid = dev_idx + 1
proc_uuid = dev_idx * 10000000 + 1
# Define Process with EXPLICIT child sorting
proc_name = dev
proc_desc = make_process_descriptor(pid, proc_name)
proc_packet = make_trace_packet(0, track_descriptor=make_track_descriptor(proc_uuid, process=proc_desc, child_ordering=3))
write_trace_packet_to_file(f, proc_packet)
# Define Operators Track as a thread track
op_track_uuid = dev_idx * 10000000 + 2
op_tid = pid * 100 + 8
op_thread_desc = make_thread_descriptor(pid, op_tid, "Ops", sort_index=1)
op_packet = make_trace_packet(0, track_descriptor=make_track_descriptor(op_track_uuid, parent_uuid=proc_uuid, thread=op_thread_desc))
write_trace_packet_to_file(f, op_packet)
# Define HMX Thread Track at rank 2
hmx_track_uuid = dev_idx * 10000000 + 20
hmx_tid = pid * 100 + 9
hmx_thread_desc = make_thread_descriptor(pid, hmx_tid, "HMX", sort_index=2)
hmx_packet = make_trace_packet(0, track_descriptor=make_track_descriptor(hmx_track_uuid, parent_uuid=proc_uuid, thread=hmx_thread_desc))
write_trace_packet_to_file(f, hmx_packet)
# Define Thread Tracks (T0, T1, ..., T9) for this device
dev_used_tracks = {uuid: val for uuid, val in used_tracks.items() if val[0] == dev}
unique_threads = sorted(list(set(t for (_, t, _, _) in dev_used_tracks.values() if t != 10)))
for t in unique_threads:
thread_uuid = dev_idx * 10000000 + 10 + t
thread_name = f"T{t}"
sort_index = 3 + t
tid = pid * 100 + 10 + t
thread_desc = make_thread_descriptor(pid, tid, thread_name, sort_index=sort_index)
thread_packet = make_trace_packet(0, track_descriptor=make_track_descriptor(
thread_uuid,
parent_uuid=proc_uuid,
thread=thread_desc,
sibling_order_rank=sort_index,
child_ordering=3 # Explicit child sorting for sub-tracks
))
write_trace_packet_to_file(f, thread_packet)
# Define Track descriptors for sub-tracks parented to thread tracks
for uuid in sorted(used_tracks.keys()):
dev, t, evt, slot = used_tracks[uuid]
dev_idx = device_to_idx[dev]
if t == 10:
continue
name = f"T{t} {evt}"
rank = 0 if evt == "HVX" else 1
parent_thread_uuid = dev_idx * 10000000 + 10 + t
# Sibling merge behavior: 1 (SIBLING_MERGE_BEHAVIOR_BY_TRACK_NAME)
track_desc = make_track_descriptor(
uuid=uuid,
name=name,
parent_uuid=parent_thread_uuid,
sibling_merge_behavior=1,
sibling_order_rank=rank
)
track_packet = make_trace_packet(0, track_descriptor=track_desc)
write_trace_packet_to_file(f, track_packet)
# Emit Operators
last_op_end_ns = defaultdict(int)
for op in filtered_ops:
dev = op['device']
dev_idx = device_to_idx[dev]
tm = time_mappers[dev]
op_start_ns = tm.cycle_to_ns(op['start_cycles'])
op_dur_ns = tm.dur_cycles_to_ns(op['start_cycles'], op['cycles'])
if op['name'] != "OPBATCH":
if op_start_ns < last_op_end_ns[dev]:
op_start_ns = last_op_end_ns[dev]
clamped_dur = max(op_dur_ns, 100) # Clamp to 100ns (0.1us)
last_op_end_ns[dev] = op_start_ns + clamped_dur
else:
clamped_dur = max(op_dur_ns, 100)
# Debug annotations for Ops
debug_annots = []
if 'line_num' in op:
debug_annots.append(make_debug_annotation("line", int_val=op['line_num']))
if 'names' in op and op['names'] and op['names'] != '----':
debug_annots.append(make_debug_annotation("names", string_val=op['names']))
if 'strides' in op and op['strides'] and op['strides'] != '----':
debug_annots.append(make_debug_annotation("strides", string_val=op['strides']))
if 'params' in op and op['params'] and op['params'] != '----':
debug_annots.append(make_debug_annotation("params", string_val=op['params']))
if 'evt' in op and op['evt']:
debug_annots.append(make_debug_annotation("evt", string_val=op['evt']))
op_track_uuid = dev_idx * 10000000 + 2
# Slice Begin
evt_begin = make_track_event(1, op_track_uuid, name=f"{op['name']} ({op['dims']})", category="operator", debug_annotations=debug_annots)
packet_begin = make_trace_packet(op_start_ns, track_event=evt_begin)
write_trace_packet_to_file(f, packet_begin)
# Slice End
evt_end = make_track_event(2, op_track_uuid)
packet_end = make_trace_packet(op_start_ns + clamped_dur, track_event=evt_end)
write_trace_packet_to_file(f, packet_end)
# Emit Thread Trace Events
for e in completed_events:
norm_name = normalize_event_name(e['event'], e['info'])
if norm_name == "DMA":
name = f"DMA {e['info']}"
elif norm_name == "FENCE":
name = f"FENCE {e['info']}" if e.get('info') is not None and e['info'] != 0 else "FENCE"
else:
name = norm_name
if e.get('missing_start') or e.get('missing_stop'):
name += "!"
debug_annots = []
if 'line_num' in e and e['line_num'] is not None:
debug_annots.append(make_debug_annotation("line", int_val=e['line_num']))
if norm_name == "FENCE" and e.get('info') is not None:
debug_annots.append(make_debug_annotation("seq", int_val=e['info']))
elif norm_name == "DMA" and e.get('info') is not None:
debug_annots.append(make_debug_annotation("channel", int_val=e['info']))
elif e.get('info') is not None and e['info'] != 0:
debug_annots.append(make_debug_annotation("info", int_val=e['info']))
if e.get('missing_start'):
debug_annots.append(make_debug_annotation("missing_start", string_val="true"))
if e.get('missing_stop'):
debug_annots.append(make_debug_annotation("missing_stop", string_val="true"))
# Slice Begin
evt_begin = make_track_event(1, e['uuid'], name=name, category="trace", debug_annotations=debug_annots if debug_annots else None)
packet_begin = make_trace_packet(e['ts_ns'], track_event=evt_begin)
write_trace_packet_to_file(f, packet_begin)
# Slice End
evt_end = make_track_event(2, e['uuid'])
packet_end = make_trace_packet(e['ts_ns'] + e['dur_ns'], track_event=evt_end)
write_trace_packet_to_file(f, packet_end)
logger.info(f"Successfully generated Perfetto trace at {output_path}")
def main():
parser = argparse.ArgumentParser(description="Convert Hexagon Op profile logs to native Perfetto Protobuf traces.")
parser.add_argument("logfile", help="Path to hex-log profile file")
parser.add_argument("-o", "--output", default="optrace.perfetto-trace", help="Output trace file path (default: optrace.perfetto-trace)")
parser.add_argument("--filter", type=str, help="Regex filter matching against the original profile-op line")
parser.add_argument("--device", type=str, help="Device to filter by (e.g. HTP0, HTP0:0) or 'split' to generate separate files per device")
group = parser.add_mutually_exclusive_group()
group.add_argument("--head", type=int, help="Limit to first N ops")
group.add_argument("--tail", type=int, help="Limit to last N ops")
args = parser.parse_args()
logging.basicConfig(level=logging.INFO, format="%(message)s", stream=sys.stdout)
op_filter_re = None
if args.filter:
try:
op_filter_re = re.compile(args.filter)
except re.error as e:
logger.error(f"Invalid regex filter: {e}")
sys.exit(1)
limit = args.head if args.head is not None else None
device_filter = args.device if (args.device and args.device != "split") else None
ops, traces = parse_log(args.logfile, limit=limit, device_filter=device_filter, op_filter_re=op_filter_re)
if args.device and args.device != "split":
ops = [op for op in ops if device_matches(op['device'], args.device)]
traces = [t for t in traces if device_matches(t['device'], args.device)]
if args.filter:
try:
filter_re = re.compile(args.filter)
except re.error as e:
logger.error(f"Invalid regex filter: {e}")
sys.exit(1)
ops = [op for op in ops if filter_re.search(op['op_text'])]
if args.head is not None or args.tail is not None:
ops_by_dev = defaultdict(list)
for op in ops:
ops_by_dev[op['device']].append(op)
filtered_ops = []
for dev in sorted(ops_by_dev.keys()):
dev_ops = ops_by_dev[dev]
if args.head is not None:
dev_ops = dev_ops[:args.head]
elif args.tail is not None:
dev_ops = dev_ops[-args.tail:]
filtered_ops.extend(dev_ops)
ops = filtered_ops
if args.filter or args.head is not None or args.tail is not None:
# Group valid ranges by device
valid_ranges_by_dev = defaultdict(list)
for op in ops:
start_cyc = op['unwrapped_cycles_start']
end_cyc = start_cyc + op['cycles'] if start_cyc is not None else None
if start_cyc is not None and end_cyc is not None:
valid_ranges_by_dev[op['device']].append((start_cyc, end_cyc))
for dev in valid_ranges_by_dev:
valid_ranges_by_dev[dev].sort(key=lambda r: r[0])
range_starts_by_dev = {dev: [r[0] for r in ranges] for dev, ranges in valid_ranges_by_dev.items()}
filtered_traces = []
for e in traces:
cyc = e['unwrapped_cycles']
if cyc is None:
continue
dev = e['device']
range_starts = range_starts_by_dev.get(dev)
if not range_starts:
continue
idx = bisect.bisect_right(range_starts, cyc) - 1
if idx >= 0:
start, end = valid_ranges_by_dev[dev][idx]
if start <= cyc <= end:
filtered_traces.append(e)
traces = filtered_traces
if args.device == "split":
unique_devices = sorted(list(set(op['device'] for op in ops)))
for dev in unique_devices:
dev_ops = [op for op in ops if device_matches(op['device'], dev)]
dev_traces = [t for t in traces if device_matches(t['device'], dev)]
out_path = get_split_output_path(args.output, dev)
generate_perfetto_trace(dev_ops, dev_traces, out_path)
else:
generate_perfetto_trace(ops, traces, args.output)
if __name__ == "__main__":
main()
@@ -0,0 +1,22 @@
Appium-Python-Client==5.2.4
attrs==25.4.0
certifi==2025.10.5
exceptiongroup==1.3.0
h11==0.16.0
idna==3.11
iniconfig==2.1.0
outcome==1.3.0.post0
packaging==25.0
pluggy==1.6.0
PySocks==1.7.1
pytest==8.4.2
selenium==4.36.0
sniffio==1.3.1
sortedcontainers==2.4.0
tomli==2.3.0
trio==0.31.0
trio-websocket==0.12.2
typing_extensions==4.15.0
urllib3==2.5.0
websocket-client==1.9.0
wsproto==1.2.0
@@ -0,0 +1,663 @@
"""Run llama.cpp Hexagon tests in a single QDC job.
Bundles test scripts into one artifact and submits a single QDC job:
1. run_bench_tests_posix.py — llama-cli and llama-bench on CPU / GPU / NPU
(from scripts/snapdragon/qdc/)
Results are written to $GITHUB_STEP_SUMMARY when set (GitHub Actions).
Prerequisites:
pip install /path/to/qualcomm_device_cloud_sdk*.whl
Platform is inferred from --device:
android Appium + pytest (Android phones: SM8750 / SM8650 / SM8850)
linux BASH (Linux IoT: QCS9075M)
Required environment variables:
QDC_API_KEY API key from QDC UI -> Users -> Settings -> API Keys
Usage:
python run_qdc_jobs.py \\
--pkg-dir pkg-snapdragon/llama.cpp \\
--model-url https://.../Llama-3.2-1B-Instruct-Q4_0.gguf \\
--device SM8750
"""
from __future__ import annotations
import argparse
import enum
import logging
import os
import re
import shutil
import sys
import tempfile
import time
import xml.etree.ElementTree as ET
from dataclasses import dataclass, field
from pathlib import Path
from typing import Callable
from qualcomm_device_cloud_sdk.api import qdc_api
from qualcomm_device_cloud_sdk.logging import configure_logging
from qualcomm_device_cloud_sdk.models import (
ArtifactType,
JobMode,
JobState,
JobSubmissionParameter,
JobType,
TestFramework,
)
# configure_logging only sets up the SDK logger; basicConfig is needed for
# our own log.info to reach stdout.
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(name)s %(levelname)s - %(message)s",
handlers=[logging.StreamHandler()],
)
configure_logging(level=logging.INFO, handlers=[logging.StreamHandler()])
# Silence per-poll GET/status spam from the SDK and its HTTP client.
logging.getLogger("qualcomm_device_cloud").setLevel(logging.WARNING)
logging.getLogger("httpx").setLevel(logging.WARNING)
logging.getLogger("httpcore").setLevel(logging.WARNING)
log = logging.getLogger(__name__)
POLL_INTERVAL = 30
JOB_TIMEOUT = 3600
LOG_UPLOAD_TIMEOUT = 600
CAPACITY_TIMEOUT = 1800
CAPACITY_POLL = 60
MAX_CONCURRENT_JOBS = 5
DEFAULT_RETRIES = 0
RETRY_DELAY = 300
TERMINAL_STATES = {JobState.COMPLETED, JobState.CANCELED}
NON_TERMINAL_STATES = {JobState.DISPATCHED, JobState.RUNNING, JobState.SETUP, JobState.SUBMITTED}
class DeviceUnavailableError(Exception):
"""Raised when the QDC device resource is not available (retryable)."""
_SCRIPTS_DIR = Path(__file__).parent
_TESTS_DIR = _SCRIPTS_DIR / "tests"
# --- Shared test assets -------------------------------------------------------
_UTILS = _TESTS_DIR / "utils.py"
_CONFTEST = _TESTS_DIR / "conftest.py"
_PYTEST_LINE_RE = re.compile(
r"(?:[\w/]+\.py::)?(?:\w+::)?([\w\[\].-]+)\s+(PASSED|FAILED|ERROR|SKIPPED)"
)
_EXCLUDED_LOGS = {
"qdc_android_whole_host-000.log",
"qdc_kernel_host-000.log",
"qdc_LE_whole_host-000.log",
"qdc_LE_kernel_host-000.log",
"script.log",
}
_NON_TERMINAL_STATE_VALUES = {s.value for s in NON_TERMINAL_STATES}
# --- Android (Appium + pytest) assets ----------------------------------------
_RUN_BENCH = _TESTS_DIR / "run_bench_tests_posix.py"
_RUN_BACKEND_OPS = _TESTS_DIR / "run_backend_ops_posix.py"
_REQUIREMENTS = _SCRIPTS_DIR / "requirements.txt"
_RUN_PY = _SCRIPTS_DIR.parent / "run.py"
# --- Linux (BASH) assets ------------------------------------------------------
_RUN_LINUX_TEMPLATE = _TESTS_DIR / "linux" / "run_linux.sh"
_LINUX_ENTRY_SCRIPT = "/bin/bash /data/local/tmp/TestContent/run_linux.sh"
# =============================================================================
# Artifact builders (per platform)
# =============================================================================
@dataclass
class JobResult:
passed: bool
tests: dict[str, bool] = field(default_factory=dict)
raw_logs: dict[str, str] = field(default_factory=dict)
failure_details: dict[str, str] = field(default_factory=dict)
def _write_lf(path: Path, content: str) -> None:
"""Write text with LF line endings (required by /bin/bash on Linux)."""
with open(path, "w", encoding="utf-8", newline="\n") as f:
f.write(content)
def _build_android_artifact(
pkg_dir: Path,
stage_dir: Path,
test_mode: str,
model_url: str | None,
) -> Path:
"""Android zip (Appium/pytest). Extracted by QDC under /qdc/appium/.
Zip structure:
llama_cpp_bundle/ installed package (adb pushed to /data/local/tmp/)
run.py Snapdragon runner
tests/
utils.py shared adb helpers
conftest.py Appium pytest fixtures
test_bench_posix.py bench + cli tests (for --test bench or all)
test_backend_ops_posix.py test-backend-ops on HTP0
requirements.txt
pytest.ini addopts = --junitxml=results.xml
"""
bundle_dir = stage_dir / "llama_cpp_bundle"
shutil.copytree(pkg_dir, bundle_dir)
dest = stage_dir / "run.py"
shutil.copy(_RUN_PY, dest)
dest.chmod(0o755)
tests_dir = stage_dir / "tests"
tests_dir.mkdir()
shutil.copy(_UTILS, tests_dir / "utils.py")
shutil.copy(_CONFTEST, tests_dir / "conftest.py")
if test_mode in ("bench", "all"):
assert model_url is not None
(tests_dir / "test_bench_posix.py").write_text(
_RUN_BENCH.read_text().replace("<<MODEL_URL>>", model_url)
)
if test_mode in ("backend-ops", "all"):
shutil.copy(_RUN_BACKEND_OPS, tests_dir / "test_backend_ops_posix.py")
shutil.copy(_REQUIREMENTS, stage_dir / "requirements.txt")
(stage_dir / "pytest.ini").write_text(
"[pytest]\naddopts = --junitxml=results.xml\n"
)
zip_base = str(stage_dir / "artifact")
shutil.make_archive(zip_base, "zip", stage_dir)
return Path(f"{zip_base}.zip")
def _build_linux_artifact(
pkg_dir: Path,
stage_dir: Path,
test_mode: str,
model_url: str | None,
) -> Path:
"""Linux IoT zip (BASH framework). Extracted by QDC to /data/local/tmp/TestContent/.
Zip structure:
run_linux.sh entry script (placeholder-substituted, LF line endings)
llama_cpp_bundle/ installed package
"""
bundle_dir = stage_dir / "llama_cpp_bundle"
shutil.copytree(pkg_dir, bundle_dir)
template = _RUN_LINUX_TEMPLATE.read_text(encoding="utf-8")
rendered = template.replace("{MODEL_URL}", model_url or "").replace(
"{TEST_MODE}", test_mode
)
script_path = stage_dir / "run_linux.sh"
_write_lf(script_path, rendered)
script_path.chmod(0o755)
zip_base = str(stage_dir / "artifact")
shutil.make_archive(zip_base, "zip", stage_dir)
return Path(f"{zip_base}.zip")
# =============================================================================
# Platform enum + strategy table
# =============================================================================
class Platform(enum.Enum):
ANDROID = "android"
LINUX = "linux"
@dataclass(frozen=True)
class PlatformSpec:
test_framework: TestFramework
entry_script: str | None
build_artifact: Callable[[Path, Path, str, str | None], Path]
job_name_fmt: str
PLATFORM_SPECS: dict[Platform, PlatformSpec] = {
Platform.ANDROID: PlatformSpec(
test_framework=TestFramework.APPIUM,
entry_script=None,
build_artifact=_build_android_artifact,
job_name_fmt="{base}",
),
Platform.LINUX: PlatformSpec(
test_framework=TestFramework.BASH,
entry_script=_LINUX_ENTRY_SCRIPT,
build_artifact=_build_linux_artifact,
job_name_fmt="{base} (Linux)",
),
}
DEVICE_PLATFORM: dict[str, Platform] = {
"SM8750": Platform.ANDROID,
"SM8650": Platform.ANDROID,
"SM8850": Platform.ANDROID,
"QCS9075M": Platform.LINUX,
}
# =============================================================================
# Shared QDC job plumbing
# =============================================================================
def wait_for_job(client, job_id: str, timeout: int) -> str:
elapsed = 0
last_state = None
consecutive_errors = 0
max_consecutive_errors = 5
while elapsed < timeout:
try:
raw = qdc_api.get_job_status(client, job_id)
consecutive_errors = 0
except Exception as e:
consecutive_errors += 1
log.warning(
"Transient error polling job %s (%d/%d): %s",
job_id,
consecutive_errors,
max_consecutive_errors,
e,
)
if consecutive_errors >= max_consecutive_errors:
raise
time.sleep(POLL_INTERVAL)
elapsed += POLL_INTERVAL
continue
try:
status = JobState(raw)
except ValueError:
status = raw
if status in TERMINAL_STATES:
return raw.lower()
if raw != last_state:
log.info("Job %s: %s", job_id, raw)
last_state = raw
time.sleep(POLL_INTERVAL)
elapsed += POLL_INTERVAL
# Abort to free the QDC concurrency slot instead of leaking it.
try:
qdc_api.abort_job(client, job_id)
log.warning("Aborted job %s after timeout to free concurrency slot", job_id)
except Exception as e:
log.warning("Failed to abort job %s: %s", job_id, e)
raise TimeoutError(f"Job {job_id} did not finish within {timeout}s")
def wait_for_log_upload(client, job_id: str) -> None:
elapsed = 0
while elapsed <= LOG_UPLOAD_TIMEOUT:
try:
status = (qdc_api.get_job_log_upload_status(client, job_id) or "").lower()
except Exception as e:
log.warning("get_job_log_upload_status failed: %s — will retry", e)
status = ""
if status in {"completed", "failed"}:
return
log.info("Waiting for log upload (status=%s) ...", status)
time.sleep(POLL_INTERVAL)
elapsed += POLL_INTERVAL
log.warning("Timed out waiting for log upload after %ds", LOG_UPLOAD_TIMEOUT)
def wait_for_capacity(client, max_jobs: int = MAX_CONCURRENT_JOBS) -> None:
"""Block until the user's active (non-terminal) QDC job count is below max_jobs."""
elapsed = 0
while elapsed < CAPACITY_TIMEOUT:
jobs_page = qdc_api.get_jobs_list(client, page_number=0, page_size=50)
if jobs_page is None:
log.warning(
"Could not retrieve job list; proceeding without capacity check"
)
return
items = getattr(jobs_page, "data", []) or []
active = sum(
1 for j in items if getattr(j, "state", None) in _NON_TERMINAL_STATE_VALUES
)
if active < max_jobs:
log.info("Active QDC jobs: %d / %d — proceeding", active, max_jobs)
return
log.info(
"Active QDC jobs: %d / %d — waiting %ds ...",
active,
max_jobs,
CAPACITY_POLL,
)
time.sleep(CAPACITY_POLL)
elapsed += CAPACITY_POLL
raise TimeoutError(
f"Capacity wait timed out after {CAPACITY_TIMEOUT}s"
)
# ---------------------------------------------------------------------------
# Log parsing helpers
# ---------------------------------------------------------------------------
def _parse_junit_xml(content: str) -> tuple[dict[str, bool], dict[str, str]]:
try:
root = ET.fromstring(content)
except ET.ParseError:
return {}, {}
results: dict[str, bool] = {}
failures: dict[str, str] = {}
for tc in root.iter("testcase"):
name = tc.get("name", "")
if classname := tc.get("classname", ""):
name = f"{classname}.{name}"
failure_el = tc.find("failure")
if failure_el is None:
failure_el = tc.find("error")
results[name] = failure_el is None
if failure_el is not None:
parts = [failure_el.get("message", ""), failure_el.text or ""]
failures[name] = "\n".join(p for p in parts if p).strip()
return results, failures
def _parse_pytest_output(content: str) -> dict[str, bool]:
results: dict[str, bool] = {}
for m in _PYTEST_LINE_RE.finditer(content):
results[m.group(1)] = m.group(2) == "PASSED"
return results
def fetch_logs_and_parse_tests(
client, job_id: str, max_retries: int = 5, retry_delay: int = 30
) -> tuple[dict[str, bool], dict[str, str], dict[str, str]]:
"""Returns (test_results, raw_logs, failure_details)."""
log_files = None
for attempt in range(1, max_retries + 1):
try:
log_files = qdc_api.get_job_log_files(client, job_id)
break
except Exception as e:
if attempt < max_retries:
log.warning(
"get_job_log_files failed (attempt %d/%d): %s — retrying in %ds",
attempt, max_retries, e, retry_delay,
)
time.sleep(retry_delay)
else:
log.error(
"get_job_log_files failed after %d attempts: %s", max_retries, e
)
return {}, {}, {}
if not log_files:
log.warning("No log files returned for job %s", job_id)
return {}, {}, {}
test_results: dict[str, bool] = {}
pytest_fallback: dict[str, bool] = {}
raw_logs: dict[str, str] = {}
failure_details: dict[str, str] = {}
with tempfile.TemporaryDirectory() as tmpdir:
for lf in log_files:
zip_path = os.path.join(tmpdir, "log.zip")
log.info("Downloading log file: %s", lf.filename)
qdc_api.download_job_log_files(client, lf.filename, zip_path)
try:
shutil.unpack_archive(zip_path, tmpdir, "zip")
except Exception as e:
log.warning("Could not unpack %s as zip: %s", lf.filename, e)
for root_dir, _, files in os.walk(tmpdir):
for fname in sorted(files):
fpath = os.path.join(root_dir, fname)
content = Path(fpath).read_text(errors="replace")
if fname.endswith(".xml"):
results, failures = _parse_junit_xml(content)
test_results.update(results)
failure_details.update(failures)
elif fname.endswith(".log"):
if fname in _EXCLUDED_LOGS:
continue
log.info("--- %s ---\n%s", fname, content)
raw_logs[fname] = content
pytest_fallback.update(_parse_pytest_output(content))
return (
(test_results if test_results else pytest_fallback),
raw_logs,
failure_details,
)
def write_summary(result: JobResult, title: str = "QDC Test Results") -> None:
summary_path = os.environ.get("GITHUB_STEP_SUMMARY")
if not summary_path:
return
icon = "✅" if result.passed else "❌"
lines = [
f"## {title}\n",
f"Overall: {icon} {'PASSED' if result.passed else 'FAILED'}\n",
]
reportable = {n: ok for n, ok in result.tests.items() if "test_install" not in n}
if reportable:
lines += ["| Test | Result |", "| ---- | ------ |"]
for name, ok in reportable.items():
lines.append(f"| `{name}` | {'✅' if ok else '❌'} |")
passed_n = sum(1 for v in reportable.values() if v)
failed_n = sum(1 for v in reportable.values() if not v)
lines += ["", f"**{passed_n} passed, {failed_n} failed**"]
else:
lines.append("_No per-test data available._")
failed_names = [n for n, ok in reportable.items() if not ok]
if failed_names:
lines += ["", "### Failures"]
for name in failed_names:
detail = result.failure_details.get(name)
if detail:
lines += [
f"<details><summary><code>{name}</code></summary>",
"",
"```",
detail,
"```",
"",
"</details>",
]
if result.raw_logs:
lines += ["", "### Raw Logs"]
for fname, content in sorted(result.raw_logs.items()):
lines += [
f"<details><summary>{fname}</summary>",
"",
"```",
content.rstrip(),
"```",
"",
"</details>",
]
with open(summary_path, "a") as f:
f.write("\n".join(lines) + "\n")
# =============================================================================
# CLI + main
# =============================================================================
def parse_args() -> argparse.Namespace:
p = argparse.ArgumentParser(
description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter,
)
p.add_argument("--pkg-dir", required=True, type=Path,
help="Installed llama.cpp package directory (contains bin/ and lib/)")
p.add_argument("--model-url",
help="Direct URL to the GGUF model file (required for --test bench)")
p.add_argument("--device", required=True,
help="QDC chipset name, e.g. SM8750")
p.add_argument("--test", choices=["bench", "backend-ops", "all"], default="bench",
help="Test suite to run (default: bench)")
p.add_argument("--job-timeout", type=int, default=JOB_TIMEOUT, metavar="SECONDS",
help=f"Max seconds to wait for job completion (default: {JOB_TIMEOUT})")
p.add_argument("--retries", type=int, default=DEFAULT_RETRIES, metavar="N",
help="Number of retries when device is unavailable (default: 0)")
p.add_argument("--retry-delay", type=int, default=RETRY_DELAY, metavar="SECONDS",
help=f"Seconds to wait between retries (default: {RETRY_DELAY})")
args = p.parse_args()
if args.test in ("bench", "all") and not args.model_url:
p.error("--model-url is required when --test bench or --test all")
return args
def _submit_and_run_job(client, args, spec, target_id, artifact_id) -> JobResult:
"""Submit a QDC job and wait for results.
Raises DeviceUnavailableError for transient device/resource issues that
are worth retrying. Returns JobResult for definitive outcomes (pass or
test failure).
"""
try:
wait_for_capacity(client)
except TimeoutError:
raise DeviceUnavailableError("Capacity wait timed out — device busy")
job_name = spec.job_name_fmt.format(base="llama.cpp Hexagon tests")
job_id = qdc_api.submit_job(
public_api_client=client,
target_id=target_id,
job_name=job_name,
external_job_id=None,
job_type=JobType.AUTOMATED,
job_mode=JobMode.APPLICATION,
timeout=max(1, args.job_timeout // 60),
test_framework=spec.test_framework,
entry_script=spec.entry_script,
job_artifacts=[artifact_id],
monkey_events=None,
monkey_session_timeout=None,
job_parameters=[JobSubmissionParameter.WIFIENABLED],
)
if job_id is None:
raise DeviceUnavailableError("Job submission failed — device may be unavailable")
log.info("Job submitted: %s (device=%s)", job_id, args.device)
try:
job_status = wait_for_job(client, job_id, timeout=args.job_timeout)
except TimeoutError as e:
raise DeviceUnavailableError(str(e))
log.info("Job %s finished: %s", job_id, job_status)
wait_for_log_upload(client, job_id)
tests, raw_logs, failure_details = fetch_logs_and_parse_tests(client, job_id)
job_ok = job_status == JobState.COMPLETED.value.lower()
if not job_ok and not tests:
raise DeviceUnavailableError(
f"Job did not complete (status={job_status}) and produced no test results"
)
passed = job_ok and all(tests.values()) if tests else job_ok
if spec.test_framework == TestFramework.BASH and not tests:
log.error("No test results recovered (state=%s). Script likely never ran.", job_status)
passed = False
if not passed:
log.error("Job did not complete successfully or tests failed (status=%s)", job_status)
return JobResult(passed=passed, tests=tests, raw_logs=raw_logs, failure_details=failure_details)
def main() -> int:
args = parse_args()
platform = DEVICE_PLATFORM.get(args.device)
if platform is None:
log.error(
"Unknown device %r. Known: %s",
args.device, ", ".join(sorted(DEVICE_PLATFORM.keys())),
)
return 1
spec = PLATFORM_SPECS[platform]
api_key = os.environ.get("QDC_API_KEY")
if not api_key:
log.error("QDC_API_KEY environment variable must be set")
return 1
if not args.pkg_dir.is_dir():
log.error("--pkg-dir %s does not exist", args.pkg_dir)
return 1
client = qdc_api.get_public_api_client_using_api_key(
api_key_header=api_key,
app_name_header="llama-cpp-ci",
on_behalf_of_header="llama-cpp-ci",
client_type_header="Python",
)
target_id = qdc_api.get_target_id(client, args.device)
if target_id is None:
log.error("Could not find QDC target for device %r", args.device)
return 1
with tempfile.TemporaryDirectory() as tmpdir:
log.info("Building %s artifact (test=%s) ...", platform.value, args.test)
zip_path = spec.build_artifact(
args.pkg_dir, Path(tmpdir), args.test, args.model_url
)
log.info("Uploading artifact (%d MB) ...", zip_path.stat().st_size // 1_000_000)
artifact_id = qdc_api.upload_file(client, str(zip_path), ArtifactType.TESTSCRIPT)
if artifact_id is None:
log.error("Artifact upload failed")
return 1
max_attempts = 1 + args.retries
for attempt in range(1, max_attempts + 1):
try:
result = _submit_and_run_job(client, args, spec, target_id, artifact_id)
break
except DeviceUnavailableError as e:
if attempt < max_attempts:
log.warning(
"Attempt %d/%d failed (device unavailable): %s — retrying in %ds",
attempt, max_attempts, e, args.retry_delay,
)
time.sleep(args.retry_delay)
else:
log.error(
"Attempt %d/%d failed (device unavailable): %s — no retries left",
attempt, max_attempts, e,
)
write_summary(
JobResult(passed=False, tests={}),
title=f"QDC Device Unavailable ({args.device})",
)
return 1
else:
return 1
if args.test == "backend-ops":
title = f"Backend Ops — HTP0 ({args.device})"
elif args.test == "all":
title = f"QDC Tests ({args.device})"
else:
title = f"QDC Test Results ({args.device})"
write_summary(result, title=title)
return 0 if result.passed else 1
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,20 @@
"""Shared pytest fixtures for QDC on-device test runners."""
import os
import pytest
from appium import webdriver
from utils import options, write_qdc_log
@pytest.fixture(scope="session", autouse=True)
def driver():
return webdriver.Remote(command_executor="http://127.0.0.1:4723/wd/hub", options=options)
def pytest_sessionfinish(session, exitstatus):
xml_path = getattr(session.config.option, "xmlpath", None) or "results.xml"
if os.path.exists(xml_path):
with open(xml_path) as f:
write_qdc_log("results.xml", f.read())
@@ -0,0 +1,234 @@
#!/bin/bash
# llama.cpp Hexagon test entry script for QDC Linux IoT (BASH framework).
#
# Placeholders substituted by run_qdc_jobs.py (--platform linux) before upload:
# {MODEL_URL} direct URL to a .gguf model file
# {TEST_MODE} bench | backend-ops | all
#
# QDC extracts the artifact zip to /data/local/tmp/TestContent/ and invokes
# this script via: /bin/bash /data/local/tmp/TestContent/run_linux.sh
# Any files written under /data/local/tmp/QDC_logs/ are auto-uploaded.
set +e
umask 022
LOG_DIR=/data/local/tmp/QDC_logs
BUNDLE_DIR=/data/local/tmp/TestContent/llama_cpp_bundle
MODEL_DIR=/data/local/tmp/gguf
MODEL_PATH="$MODEL_DIR/model.gguf"
RESULTS_XML="$LOG_DIR/results.xml"
mkdir -p "$LOG_DIR" "$MODEL_DIR"
# Redirect all parent-shell output to script.log so QDC auto-uploads it;
# per-case runs still capture their own stdout/stderr into dedicated logs.
exec > "$LOG_DIR/script.log" 2>&1
echo "=== env ==="
date -u
uname -a
pwd
mount -o rw,remount / 2>/dev/null || true
cd "$BUNDLE_DIR" || { echo "FATAL: bundle missing at $BUNDLE_DIR"; exit 1; }
chmod +x bin/* 2>/dev/null
export LD_LIBRARY_PATH="$BUNDLE_DIR/lib:$LD_LIBRARY_PATH"
export ADSP_LIBRARY_PATH="$BUNDLE_DIR/lib"
export GGML_HEXAGON_EXPERIMENTAL=1
echo "=== download model ==="
MODEL_URL="{MODEL_URL}"
if [ -z "$MODEL_URL" ]; then
echo "No model URL provided, skipping download"
elif [ ! -f "$MODEL_PATH" ]; then
curl -L -fS --retry 3 --retry-delay 5 -o "$MODEL_PATH" "$MODEL_URL"
curl_rc=$?
if [ $curl_rc -ne 0 ]; then
echo "FATAL: model download failed (rc=$curl_rc)"
exit 1
fi
ls -la "$MODEL_PATH"
fi
# ---------------------------------------------------------------------------
# JUnit XML helpers
# ---------------------------------------------------------------------------
xml_open() {
printf '%s\n' \
'<?xml version="1.0" encoding="utf-8"?>' \
"<testsuites>" \
"<testsuite name=\"llama_cpp_linux\">" \
> "$RESULTS_XML"
}
xml_close() {
printf '%s\n' '</testsuite>' '</testsuites>' >> "$RESULTS_XML"
}
xml_case_pass() {
local classname=$1 name=$2
printf '<testcase classname="%s" name="%s"/>\n' "$classname" "$name" >> "$RESULTS_XML"
}
xml_case_fail() {
local classname=$1 name=$2 rc=$3 logfile=$4
{
printf '<testcase classname="%s" name="%s">\n' "$classname" "$name"
printf '<failure message="exit %s"><![CDATA[\n' "$rc"
tail -c 4096 "$logfile" 2>/dev/null | sed 's/]]>/]] >/g'
printf '\n]]></failure>\n</testcase>\n'
} >> "$RESULTS_XML"
}
# Map backend name -> "NDEV --device" pair. "none" means no offload (CPU).
backend_env() {
case "$1" in
cpu) echo "0 none" ;;
gpu) echo "0 GPUOpenCL" ;;
npu) echo "1 HTP0" ;;
esac
}
backend_log_name() {
case "$1" in
cpu) echo "cpu" ;;
gpu) echo "gpu" ;;
npu) echo "htp" ;;
esac
}
backend_device_name() {
case "$1" in
cpu) echo "none" ;;
gpu) echo "GPUOpenCL" ;;
npu) echo "HTP0" ;;
esac
}
# Append a diagnostic block when a per-case `timeout N` fires (rc=124). The
# naked log file at that point usually just ends mid-OpenCL-init with no
# stderr, which is hard to read in CI summaries.
note_timeout_if_triggered() {
local rc=$1 budget=$2 log=$3
[ "$rc" -eq 124 ] || return 0
{
printf '\n'
printf '=== TIMEOUT after %ss ===\n' "$budget"
printf 'uptime: '; uptime 2>/dev/null
printf 'free -m:\n'; free -m 2>/dev/null
printf 'loadavg: '; cat /proc/loadavg 2>/dev/null
} >> "$log"
}
completion_extra_args() {
case "$1" in
cpu) echo "--device none --ctx-size 2048 -no-cnv -n 32 --seed 42" ;;
gpu) echo "--device GPUOpenCL --ctx-size 2048 -no-cnv -n 32 --seed 42" ;;
npu) echo "--device HTP0 --ctx-size 2048 -no-cnv -n 32 --seed 42 --ubatch-size 1024" ;;
esac
}
run_completion_case() {
local name=$1
local parts=($(backend_env "$name"))
local ndev=${parts[0]} device=${parts[1]}
local device_log_name=$(backend_device_name "$name")
local log="$LOG_DIR/llama_completion_${device_log_name}.log"
local prompt="$LOG_DIR/bench_prompt.txt"
echo 'What is the capital of France?' > "$prompt"
local extra
extra=$(completion_extra_args "$name")
echo "=== [completion:$name] llama-completion --device $device (NDEV=$ndev) ==="
timeout 600 env GGML_HEXAGON_NDEV=$ndev ./bin/llama-completion \
-m "$MODEL_PATH" \
-f "$prompt" \
$extra \
> "$log" 2>&1 < /dev/null
local rc=$?
note_timeout_if_triggered "$rc" 600 "$log"
if [ $rc -eq 0 ]; then
xml_case_pass "tests.test_bench_posix" "test_llama_completion[$name]"
else
xml_case_fail "tests.test_bench_posix" "test_llama_completion[$name]" "$rc" "$log"
fi
}
run_bench_case() {
local name=$1
local parts=($(backend_env "$name"))
local ndev=${parts[0]} device=${parts[1]}
local log_suffix=$(backend_log_name "$name")
local log="$LOG_DIR/llama_bench_${log_suffix}.log"
local ubatch_arg=""
[ "$name" = "npu" ] && ubatch_arg="--ubatch-size 1024"
echo "=== [bench:$name] llama-bench --device $device (NDEV=$ndev) ==="
timeout 600 env GGML_HEXAGON_NDEV=$ndev ./bin/llama-bench \
-m "$MODEL_PATH" \
--device "$device" \
-ngl 99 \
$ubatch_arg \
-t 4 \
-p 128 \
-n 32 \
> "$log" 2>&1
local rc=$?
note_timeout_if_triggered "$rc" 600 "$log"
if [ $rc -eq 0 ]; then
xml_case_pass "tests.test_bench_posix" "test_llama_bench[$name]"
else
xml_case_fail "tests.test_bench_posix" "test_llama_bench[$name]" "$rc" "$log"
fi
}
run_backend_ops_case() {
local dtype=$1
local log="$LOG_DIR/backend_ops_${dtype}.log"
local pattern
case "$dtype" in
q4_0)
# Matches Android: exclude a known-broken shape on NPU.
pattern='^(?=.*type_a=q4_0)(?!.*type_b=f32,m=576,n=512,k=576).*$'
;;
*)
pattern="type_a=${dtype}"
;;
esac
echo "=== [backend-ops:$dtype] test-backend-ops -b HTP0 -o MUL_MAT ==="
timeout 600 env GGML_HEXAGON_NDEV=1 GGML_HEXAGON_HOSTBUF=0 ./bin/test-backend-ops \
-b HTP0 -o MUL_MAT -p "$pattern" \
> "$log" 2>&1
local rc=$?
note_timeout_if_triggered "$rc" 600 "$log"
if [ $rc -eq 0 ]; then
xml_case_pass "tests.test_backend_ops_posix" "test_backend_ops_htp0[$dtype]"
else
xml_case_fail "tests.test_backend_ops_posix" "test_backend_ops_htp0[$dtype]" "$rc" "$log"
fi
}
xml_open
case "{TEST_MODE}" in
bench)
for b in cpu gpu npu; do run_completion_case "$b"; done
for b in cpu gpu npu; do run_bench_case "$b"; done
;;
backend-ops)
for d in mxfp4 fp16 q4_0; do run_backend_ops_case "$d"; done
;;
all)
for b in cpu gpu npu; do run_completion_case "$b"; done
for b in cpu gpu npu; do run_bench_case "$b"; done
for d in mxfp4 fp16 q4_0; do run_backend_ops_case "$d"; done
;;
*)
echo "FATAL: unsupported TEST_MODE={TEST_MODE}"
;;
esac
xml_close
echo "=== done ==="
# Host parses results.xml to decide pass/fail.
exit 0
@@ -0,0 +1,48 @@
"""
On-device test-backend-ops runner for llama.cpp (HTP0 backend).
On Android: executed by QDC's Appium test framework on the QDC runner.
The runner has ADB access to the allocated device.
On Linux: runs test-backend-ops directly via run_linux.sh (BASH framework).
"""
import os
import sys
import pytest
from utils import (
BIN_PATH,
push_bundle_if_needed,
run_snapdragon,
write_qdc_log,
)
@pytest.fixture(scope="session", autouse=True)
def install(driver):
push_bundle_if_needed(f"{BIN_PATH}/test-backend-ops")
@pytest.mark.parametrize("type_a", ["mxfp4", "fp16", "q4_0"])
def test_backend_ops_htp0(type_a):
if type_a == "q4_0":
pattern = r'^(?=.*type_a=q4_0)(?!.*type_b=f32,m=576,n=512,k=576).*$'
else:
pattern = f"type_a={type_a}"
result = run_snapdragon(
["test-backend-ops", "-b", "HTP0", "-o", "MUL_MAT", "-p", pattern],
)
write_qdc_log(f"backend_ops_{type_a}.log", result.stdout or "")
assert result.returncode == 0, (
f"test-backend-ops type_a={type_a} failed (exit {result.returncode})"
)
if __name__ == "__main__":
ret = pytest.main(["-s", "--junitxml=results.xml", os.path.realpath(__file__)])
if os.path.exists("results.xml"):
with open("results.xml") as f:
write_qdc_log("results.xml", f.read())
sys.exit(ret)
@@ -0,0 +1,105 @@
"""
On-device bench and completion test runner for llama.cpp (CPU, GPU, NPU backends).
On Android: calls scripts/snapdragon/run.py on the QDC runner host
(script wraps commands in adb shell internally).
On Linux: runs llama-bench directly via run_linux.sh (BASH framework).
Placeholders replaced at artifact creation time by run_qdc_jobs.py:
<<MODEL_URL>> Direct URL to the GGUF model file (downloaded on-device)
"""
import os
import subprocess
import sys
import pytest
from utils import (
BIN_PATH,
MODEL_DEVICE_PATH,
PROMPT_DIR,
push_bundle_if_needed,
run_adb_command,
run_snapdragon,
write_qdc_log,
)
MODEL_URL = "<<MODEL_URL>>"
@pytest.fixture(scope="session", autouse=True)
def install(driver):
push_bundle_if_needed(f"{BIN_PATH}/llama-cli")
run_adb_command(f"mkdir -p /data/local/tmp/gguf {PROMPT_DIR}")
run_adb_command(f"echo 'What is the capital of France?' > {PROMPT_DIR}/bench_prompt.txt")
check = subprocess.run(
["adb", "shell", f"ls {MODEL_DEVICE_PATH}"],
text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
)
if check.returncode != 0:
run_adb_command(f'curl -L -J --output {MODEL_DEVICE_PATH} "{MODEL_URL}"')
@pytest.mark.parametrize(
"device",
[
pytest.param("none", id="cpu"),
pytest.param("GPUOpenCL", id="gpu"),
pytest.param("HTP0", id="npu"),
],
)
def test_llama_completion(device):
args = [
"llama-completion",
"-m", MODEL_DEVICE_PATH,
"-f", f"{PROMPT_DIR}/bench_prompt.txt",
"-no-cnv",
"--ctx-size", "8192",
"-n", "128",
"--seed", "42",
]
if device == "HTP0":
args += ["--ubatch-size", "1024"]
result = run_snapdragon(args, device=device)
write_qdc_log(f"llama_completion_{device}.log", result.stdout or "")
assert result.returncode == 0, (
f"llama-completion {device} failed (exit {result.returncode})"
)
_DEVICE_LOG_NAME = {"none": "cpu", "GPUOpenCL": "gpu", "HTP0": "htp"}
@pytest.mark.parametrize(
"device",
[
pytest.param("none", id="cpu"),
pytest.param("GPUOpenCL", id="gpu"),
pytest.param("HTP0", id="npu"),
],
)
def test_llama_bench(device):
args = [
"llama-bench",
"-m", MODEL_DEVICE_PATH,
"-ngl", "99",
"-p", "128",
"-n", "32",
]
if device == "HTP0":
args += ["--ubatch-size", "1024"]
result = run_snapdragon(args, device=device)
write_qdc_log(f"llama_bench_{_DEVICE_LOG_NAME[device]}.log", result.stdout or "")
assert result.returncode == 0, (
f"llama-bench {device} failed (exit {result.returncode})"
)
if __name__ == "__main__":
ret = pytest.main(["-s", "--junitxml=results.xml", os.path.realpath(__file__)])
if os.path.exists("results.xml"):
with open("results.xml") as f:
write_qdc_log("results.xml", f.read())
sys.exit(ret)
@@ -0,0 +1,152 @@
"""Shared helpers for QDC on-device test runners."""
from __future__ import annotations
import logging
import os
import subprocess
import sys
import tempfile
from appium.options.common import AppiumOptions
log = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# On-device paths
# ---------------------------------------------------------------------------
BUNDLE_PATH = "/data/local/tmp/llama.cpp"
BIN_PATH = f"{BUNDLE_PATH}/bin"
LIB_PATH = f"{BUNDLE_PATH}/lib"
QDC_LOGS_PATH = "/data/local/tmp/QDC_logs"
SCRIPTS_DIR = "/qdc/appium"
MODEL_NAME = "model.gguf"
MODEL_DEVICE_PATH = "/data/local/tmp/gguf/model.gguf"
PROMPT_DIR = "/data/local/tmp/scorecard_prompts"
# ---------------------------------------------------------------------------
# Appium session options
# ---------------------------------------------------------------------------
options = AppiumOptions()
options.set_capability("automationName", "UiAutomator2")
options.set_capability("platformName", "Android")
options.set_capability("deviceName", os.getenv("ANDROID_DEVICE_VERSION"))
# ---------------------------------------------------------------------------
# Shell / process helpers
# ---------------------------------------------------------------------------
def write_qdc_log(filename: str, content: str) -> None:
"""Write content as a log file for QDC log collection."""
subprocess.run(
["adb", "shell", f"mkdir -p {QDC_LOGS_PATH}"],
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
)
with tempfile.NamedTemporaryFile(mode="w", suffix=".log", delete=False) as f:
f.write(content)
tmp_path = f.name
try:
subprocess.run(
["adb", "push", tmp_path, f"{QDC_LOGS_PATH}/{filename}"],
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
)
finally:
os.unlink(tmp_path)
def ensure_bundle(check_binary: str | None = None) -> None:
"""Ensure the llama_cpp_bundle is available on the target device."""
push_bundle_if_needed(check_binary or f"{BIN_PATH}/llama-cli")
# ---------------------------------------------------------------------------
# Android / Linux host helpers
# ---------------------------------------------------------------------------
def run_adb_command(cmd: str, *, check: bool = True) -> subprocess.CompletedProcess:
"""Run a command on-device via ``adb shell`` with exit-code sentinel."""
raw = subprocess.run(
["adb", "shell", f"{cmd}; echo __RC__:$?"],
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
)
stdout = raw.stdout
returncode = raw.returncode
if stdout:
lines = stdout.rstrip("\n").split("\n")
if lines and lines[-1].startswith("__RC__:"):
try:
returncode = int(lines[-1][7:])
stdout = "\n".join(lines[:-1]) + "\n"
except ValueError:
pass
log.info(stdout)
result = subprocess.CompletedProcess(raw.args, returncode, stdout=stdout)
if check:
assert returncode == 0, f"Command failed (exit {returncode})"
return result
def run_snapdragon(
cmd_args: list[str],
*,
device: str | None = None,
extra_run_args: list[str] | None = None,
extra_env: dict[str, str] | None = None,
) -> subprocess.CompletedProcess:
"""Run a tool via scripts/snapdragon/run.py targeting android."""
env = os.environ.copy()
env["GGML_HEXAGON_EXPERIMENTAL"] = "1"
if extra_env:
env.update(extra_env)
cmd = [sys.executable, f"{SCRIPTS_DIR}/run.py", "--target", "android"]
if device is not None:
cmd.extend(["-d", device])
if extra_run_args:
cmd.extend(extra_run_args)
cmd.append("--")
cmd.extend(cmd_args)
result = subprocess.run(
cmd, env=env,
text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
)
log.info(result.stdout)
return result
def adb_shell(cmd: str) -> None:
"""Run a command via adb shell (fire-and-forget, no error check)."""
subprocess.run(
["adb", "shell", "sh", "-c", cmd],
capture_output=True, encoding="utf-8", errors="replace", check=False,
)
def push_bundle_if_needed(check_binary: str) -> None:
"""Push llama_cpp_bundle to the device if check_binary is not already present."""
result = subprocess.run(
["adb", "shell", f"ls {check_binary}"],
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
)
if result.returncode != 0:
subprocess.run(
["adb", "push", "/qdc/appium/llama_cpp_bundle/", BUNDLE_PATH],
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
)
subprocess.run(
["adb", "shell", f"find {BUNDLE_PATH}/bin -type f -exec chmod 755 {{}} +"],
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
)
+569
View File
@@ -0,0 +1,569 @@
#!/usr/bin/env python3
#
# Run llama.cpp tools on Snapdragon devices (natively, via ADB, or SSH).
#
import sys
import os
import argparse
import subprocess
import platform
import shlex
import logging
logger = logging.getLogger("run")
MANAGED_ENV_NAMES = (
"GGML_HEXAGON_DEVICES",
"GGML_HEXAGON_VERBOSE",
"GGML_HEXAGON_PROFILE",
"GGML_HEXAGON_NHVX",
"GGML_HEXAGON_NHMX",
"GGML_HEXAGON_HOSTBUF",
"GGML_HEXAGON_DMA64",
"GGML_HEXAGON_OPBATCH",
"GGML_HEXAGON_OPQUEUE",
"GGML_HEXAGON_OPPOLL",
"GGML_HEXAGON_OPFILTER",
"GGML_HEXAGON_OPFUSION",
"GGML_HEXAGON_RT_REPACK",
"GGML_HEXAGON_VMEM",
"GGML_HEXAGON_MBUF",
"GGML_HEXAGON_MM_SELECT",
"GGML_HEXAGON_FA_SELECT",
"GGML_HEXAGON_FA_HEAD_SPLIT",
"GGML_HEXAGON_GDN_SELECT",
"GGML_HEXAGON_AR_SELECT",
"GGML_HEXAGON_AR_SCATTER",
"GGML_HEXAGON_ETM",
"GGML_HEXAGON_ARCH",
"GGML_HEXAGON_OPTRACE",
"GGML_OPENCL_PLATFORM",
"GGML_OPENCL_DEVICE",
"GGML_OPENCL_OPFILTER",
"GGML_OPENCL_KERNEL_CACHE_DIR",
"GGML_OPENCL_KERNEL_CACHE_DEBUG",
"GGML_OPENCL_FA_TUNE",
"GGML_OPENCL_DISABLE_FUSION",
"GGML_OPENCL_ADRENO_XMEM_GEMM",
"GGML_OPENCL_ADRENO_USE_LARGE_BUFFER",
"GGML_SCHED_DEBUG",
"ADSP_LIBRARY_PATH",
"CDSP_LIBRARY_PATH",
"CDSP1_LIBRARY_PATH",
"CDSP2_LIBRARY_PATH",
"CDSP3_LIBRARY_PATH",
"MTMD_BACKEND_DEVICE",
"D",
"DEVICE",
)
DSP_LIBRARY_ENV_NAMES = (
"ADSP_LIBRARY_PATH",
"CDSP_LIBRARY_PATH",
"CDSP1_LIBRARY_PATH",
"CDSP2_LIBRARY_PATH",
"CDSP3_LIBRARY_PATH",
)
def parse_target(target_str):
if not target_str:
return None, None, None
parts = target_str.split(":")
target_name = parts[0]
if target_name in ("adb", "android"):
serial = ":".join(parts[1:]) if len(parts) > 1 else None
return "android", serial, "adb"
elif target_name in ("lnx", "linux", "ubuntu"):
if len(parts) > 1 and parts[1] == "adb":
serial = ":".join(parts[2:]) if len(parts) > 2 else None
return "linux", serial, "adb"
host = ":".join(parts[1:]) if len(parts) > 1 else None
return "linux", host, "ssh"
elif target_str in ("wos", "windows"):
return "windows", None, None
else:
return None, None, None
def shlex_join(args_list):
if hasattr(shlex, 'join'):
return shlex.join(args_list)
import pipes
return " ".join(pipes.quote(x) for x in args_list)
def default_target_dir(target_type, target_transport, home_dir):
if target_type == "android":
return "/data/local/tmp/llama.cpp"
if target_type == "linux":
if home_dir:
return home_dir.rstrip("/") + "/llama.cpp"
if target_transport == "adb":
return "/home/llama.cpp"
return "~/llama.cpp"
return "~/llama.cpp"
def library_env_parts(lib_dir):
env_parts = [f"LD_LIBRARY_PATH={lib_dir}"]
for name in DSP_LIBRARY_ENV_NAMES:
env_parts.append(f"{name}={lib_dir}")
return env_parts
def adb_root(adb_base):
root_cmd = adb_base + ["root"]
logger.info(f"+ {' '.join(root_cmd)}")
res = subprocess.run(root_cmd)
if res.returncode != 0:
logger.error("ADB root failed.")
sys.exit(res.returncode)
wait_cmd = adb_base + ["wait-for-device"]
logger.info(f"+ {' '.join(wait_cmd)}")
res = subprocess.run(wait_cmd)
if res.returncode != 0:
logger.error("ADB wait-for-device failed.")
sys.exit(res.returncode)
def split_device_list(devices):
parts = []
curr = []
bracket_depth = 0
for ch in devices:
if ch == '[':
bracket_depth += 1
curr.append(ch)
elif ch == ']':
if bracket_depth > 0:
bracket_depth -= 1
curr.append(ch)
elif ch == ',' and bracket_depth == 0:
part = "".join(curr).strip()
if part:
parts.append(part)
curr = []
else:
curr.append(ch)
part = "".join(curr).strip()
if part:
parts.append(part)
return parts
def device_arg_from_devices(devices):
if devices.isdigit():
n = int(devices)
return ",".join(f"HTP{i}" for i in range(n))
names = []
for part in split_device_list(devices):
if "[" in part:
part = part.split("[", 1)[0].strip()
if part:
names.append(part)
return ",".join(names)
def normalize_cmd_device_args(cmd_args):
for i, arg in enumerate(cmd_args):
if arg == "--device" and i + 1 < len(cmd_args):
cmd_args[i + 1] = device_arg_from_devices(cmd_args[i + 1])
elif arg.startswith("--device="):
cmd_args[i] = "--device=" + device_arg_from_devices(arg.split("=", 1)[1])
def main():
logging.basicConfig(level=logging.INFO, format='%(message)s')
# Split arguments at '--'
if '--' in sys.argv:
idx = sys.argv.index('--')
run_args = sys.argv[1:idx]
cmd_args = sys.argv[idx + 1:]
else:
run_args = sys.argv[1:]
cmd_args = []
parser = argparse.ArgumentParser(
description="Unified runner for llama.cpp tools on Snapdragon (natively, via ADB, or via SSH)."
)
parser.add_argument("--target", help="Execution target (e.g. android[:serial]/adb[:serial], ubuntu:adb[:serial], linux:[user@]host/lnx:[user@]host/ubuntu:[user@]host, windows/wos) (default: local run)")
parser.add_argument("--target-dir", help="Target directory on the device (default: /data/local/tmp/llama.cpp for Android, ~/llama.cpp for Linux, /home/llama.cpp for Linux via ADB)")
parser.add_argument("--home-dir", help="Home directory for Linux targets when deriving the default target directory")
parser.add_argument("--install-dir", help="Install directory name (defaults to pkg-TARGET or pkg-TARGET-dbg prefix based on target)")
parser.add_argument("--debug", action="store_true", help="Use debug build (defaults to pkg-TARGET-dbg folder)")
parser.add_argument("--devices", "--device", "-d", help="Select execution devices (split into NPU and OpenCL GPUs automatically, default: HTP0)")
parser.add_argument("--verbose", help="Verbose level (enables both Hexagon and OpenCL kernel cache debugging)")
parser.add_argument("--profile", help="Profiling flag (enables Hexagon profiling and OpenCL autotuning)")
parser.add_argument("--sched-debug", action="store_true", help="Enable GGML/llama.cpp scheduler debug output (GGML_SCHED_DEBUG=2)")
parser.add_argument("--mtmd-device", help="Specify the backend device ID for Multi-Threaded Multi-Device setup (MTMD_BACKEND_DEVICE)")
parser.add_argument("--no-embd-offload", action="store_true", help="Keep token embeddings and output projection on CPU (-ot token_embd.weight=CPU,output.weight=CPU)")
# Hexagon specific parameters
parser.add_argument("--hex-verbose", help="Enable verbose logging (GGML_HEXAGON_VERBOSE)")
parser.add_argument("--hex-profile", help="Enable NPU/Hexagon profiling and performance metrics print (GGML_HEXAGON_PROFILE)")
parser.add_argument("--hex-nhvx", help="Number of HVX units to use (GGML_HEXAGON_NHVX)")
parser.add_argument("--hex-nhmx", help="Number of HMX units to use. 0 disables HMX power-up (GGML_HEXAGON_NHMX)")
parser.add_argument("--hex-power-mode", help="HTP DCVS/HMX power mode: low_power_saver, power_saver, high_power_saver, low_balanced, balanced, high_performance, sustained_high_performance, burst (GGML_HEXAGON_POWER_MODE)")
parser.add_argument("--hex-hostbuf", help="Enable host buffers (GGML_HEXAGON_HOSTBUF)")
parser.add_argument("--hex-dma64", nargs="?", const="1", help="Enable (1) or disable (0) 64-bit DMA for model weights (GGML_HEXAGON_DMA64)")
parser.add_argument("--hex-opbatch", help="Maximum number of operations to batch into a single HTP execution (GGML_HEXAGON_OPBATCH)")
parser.add_argument("--hex-opqueue", help="Size of the asynchronous NPU operation queue (GGML_HEXAGON_OPQUEUE)")
parser.add_argument("--hex-oppoll", default="1", help="Enable (1) or Disable (0) polling for NPU opbatch completion (GGML_HEXAGON_OPPOLL) (default: 1)")
parser.add_argument("--hex-opfilter", help="Regex pattern to filter/select which operators are offloaded to NPU (GGML_HEXAGON_OPFILTER)")
parser.add_argument("--hex-opfusion", help="NPU graph node fusion optimization level (0: disabled, 1: enabled) (GGML_HEXAGON_OPFUSION)")
parser.add_argument("--hex-rt-repack", help="Runtime weight repacking mode (0: disabled, 1: quantized only, 2: always) (GGML_HEXAGON_RT_REPACK)")
parser.add_argument("--hex-vmem", help="Maximum NPU VMEM size limit in MB to allocate (GGML_HEXAGON_VMEM)")
parser.add_argument("--hex-mbuf", help="Host buffer size limits in MB (supports K/M/G suffix): <dyn>[,<static>[,<total>]] (default: 512,1024,0) (GGML_HEXAGON_MBUF)")
parser.add_argument("--hex-mm-select", help="Select MUL_MAT and MUL_MAT_ID kernel (GGML_HEXAGON_MM_SELECT) 2:HMX,1:HVX,0:disable")
parser.add_argument("--hex-fa-select", help="Select Flash Attention kernel (GGML_HEXAGON_FA_SELECT) 2:HMX,1:HVX,0:disable")
parser.add_argument("--hex-fa-head-split", help="Enable (1) or disable (0) head-parallel flash_attn partitioning (GGML_HEXAGON_FA_HEAD_SPLIT)")
parser.add_argument("--hex-gdn-select", help="Select Gated Delta Net kernel (GGML_HEXAGON_GDN_SELECT) 2:HMX,1:HVX,0:disable")
parser.add_argument("--hex-ar-select", help="Select All-Reduce kernel (GGML_HEXAGON_AR_SELECT) 1:enable,0:disable")
parser.add_argument("--hex-ar-scatter", help="Enable (1) or disable (0) reduce-scatter for fused ALLREDUCE+ADD (GGML_HEXAGON_AR_SCATTER)")
parser.add_argument("--hex-etm", help="Enable Embedded Trace Macrocell hardware tracing / trace logging (GGML_HEXAGON_ETM)")
parser.add_argument("--hex-arch", help="Target Hexagon NPU architecture version override (v73, v75, v79, v81, etc.) (GGML_HEXAGON_ARCH)")
parser.add_argument("--hex-optrace", help="Trace buffer size in number of records (GGML_HEXAGON_OPTRACE)")
# OpenCL specific parameters
parser.add_argument("--cl-platform", help="Select OpenCL platform name/regex (e.g. Qualified Qualcomm OpenCL platform) (GGML_OPENCL_PLATFORM)")
parser.add_argument("--cl-device", help="Select OpenCL device name/regex (e.g. Adreno GPU) (GGML_OPENCL_DEVICE)")
parser.add_argument("--cl-opfilter", help="Regex pattern to filter/select which operators are offloaded to OpenCL (GGML_OPENCL_OPFILTER)")
parser.add_argument("--cl-disable-fusion", action="store_true", help="Disable OpenCL kernel fusion optimizations (GGML_OPENCL_DISABLE_FUSION)")
parser.add_argument("--cl-cache-dir", help="Directory path to store compiled OpenCL program binaries (GGML_OPENCL_KERNEL_CACHE_DIR)")
parser.add_argument("--cl-cache-debug", help="Enable verbose debugging logs for the kernel caching system (GGML_OPENCL_KERNEL_CACHE_DEBUG)")
parser.add_argument("--cl-fa-tune", action="store_true", help="Enable automatic Flash Attention kernel autotuning (GGML_OPENCL_FA_TUNE)")
parser.add_argument("--cl-adreno-xmem", action="store_true", help="Enforce matmul using texture/image (xmem) memory paths on Adreno GPUs (GGML_OPENCL_ADRENO_XMEM_GEMM)")
parser.add_argument("--cl-adreno-large-buffer", action="store_true", help="Allow allocating larger buffer sizes on Adreno GPUs (GGML_OPENCL_ADRENO_USE_LARGE_BUFFER)")
args = parser.parse_args(run_args)
if not cmd_args:
parser.print_help()
logger.error("\nError: No command specified after '--'")
sys.exit(1)
target_type = None
target_val = None
target_transport = None
target_prefix = None
if args.target:
target_type, target_val, target_transport = parse_target(args.target)
if not target_type:
logger.error(f"Error: Invalid target format '{args.target}'. Must be android[:serial]/adb[:serial], ubuntu:adb[:serial], linux:[user@]host/lnx:[user@]host/ubuntu:[user@]host, or windows/wos.")
sys.exit(1)
target_prefix = args.target.split(":", 1)[0]
# Resolve install directory
install_dir = args.install_dir
if not install_dir:
if target_prefix:
suffix = "-dbg" if args.debug else ""
install_dir = f"pkg-{target_prefix}{suffix}"
else:
# Smart branch folder detection for local run if default is not set
prefixes = ("wos", "windows", "lnx", "linux", "ubuntu", "adb", "android")
suffixes = ("-dbg", "") if args.debug else ("", "-dbg")
found = False
for suffix in suffixes:
for prefix in prefixes:
test_path = f"./pkg-{prefix}{suffix}/llama.cpp"
if os.path.exists(test_path):
install_dir = f"pkg-{prefix}{suffix}"
found = True
break
if found:
break
if not install_dir:
install_dir = "pkg-android" # Fallback default
# Host side package path
package_path = os.path.join(install_dir, "llama.cpp")
# Environment variables to map
env_vars = {}
def set_env(env_name, opt_val):
if opt_val is not None:
env_vars[env_name] = str(opt_val)
# Resolve and filter devices (HTP vs OpenCL)
device_in_cmd = None
for i, arg in enumerate(cmd_args):
if arg == "--device" and i + 1 < len(cmd_args):
device_in_cmd = cmd_args[i + 1]
break
elif arg.startswith("--device="):
device_in_cmd = arg.split("=", 1)[1]
break
if args.devices is not None:
devices_val = args.devices
elif device_in_cmd is not None:
devices_val = device_in_cmd
else:
devices_val = "HTP0"
if devices_val.isdigit():
hex_devices = devices_val
cl_device = ""
else:
parts = split_device_list(devices_val)
# Any device containing "htp" is Hexagon, rest is OpenCL
hex_parts = [p for p in parts if "htp" in p.lower()]
cl_parts = [
p for p in parts
if "htp" not in p.lower()
and p.lower() not in ("none", "cpu")
and not p.lower().startswith("gpuopencl")
]
hex_devices = ",".join(hex_parts)
cl_device = ",".join(cl_parts)
# Set Hexagon devices
if hex_devices:
env_vars["GGML_HEXAGON_DEVICES"] = hex_devices
normalize_cmd_device_args(cmd_args)
# Set OpenCL device (unless overridden by --cl-device)
final_cl_device = args.cl_device if args.cl_device is not None else cl_device
if final_cl_device:
env_vars["GGML_OPENCL_DEVICE"] = final_cl_device
# Map shared & backend-specific parameters with correct overrides
# Verbose logging mapping
hex_verbose_val = args.hex_verbose if args.hex_verbose is not None else args.verbose
set_env("GGML_HEXAGON_VERBOSE", hex_verbose_val)
cl_cache_debug_val = args.cl_cache_debug if args.cl_cache_debug is not None else args.verbose
set_env("GGML_OPENCL_KERNEL_CACHE_DEBUG", cl_cache_debug_val)
# Profiling mapping
hex_profile_val = args.hex_profile if args.hex_profile is not None else args.profile
set_env("GGML_HEXAGON_PROFILE", hex_profile_val)
if args.cl_fa_tune or args.profile is not None:
env_vars["GGML_OPENCL_FA_TUNE"] = "1"
# Other Hexagon environment variables
set_env("GGML_HEXAGON_NHVX", args.hex_nhvx)
set_env("GGML_HEXAGON_NHMX", args.hex_nhmx)
set_env("GGML_HEXAGON_POWER_MODE", args.hex_power_mode)
set_env("GGML_HEXAGON_HOSTBUF", args.hex_hostbuf)
set_env("GGML_HEXAGON_DMA64", args.hex_dma64)
set_env("GGML_HEXAGON_OPBATCH", args.hex_opbatch)
set_env("GGML_HEXAGON_OPQUEUE", args.hex_opqueue)
set_env("GGML_HEXAGON_OPPOLL", args.hex_oppoll)
set_env("GGML_HEXAGON_OPFILTER", args.hex_opfilter)
set_env("GGML_HEXAGON_OPFUSION", args.hex_opfusion)
set_env("GGML_HEXAGON_RT_REPACK", args.hex_rt_repack)
set_env("GGML_HEXAGON_VMEM", args.hex_vmem)
set_env("GGML_HEXAGON_MBUF", args.hex_mbuf)
set_env("GGML_HEXAGON_MM_SELECT", args.hex_mm_select)
set_env("GGML_HEXAGON_FA_SELECT", args.hex_fa_select)
set_env("GGML_HEXAGON_FA_HEAD_SPLIT", args.hex_fa_head_split)
set_env("GGML_HEXAGON_GDN_SELECT", args.hex_gdn_select)
set_env("GGML_HEXAGON_AR_SELECT", args.hex_ar_select)
set_env("GGML_HEXAGON_AR_SCATTER", args.hex_ar_scatter)
set_env("GGML_HEXAGON_ETM", args.hex_etm)
set_env("GGML_HEXAGON_ARCH", args.hex_arch)
set_env("GGML_HEXAGON_OPTRACE", args.hex_optrace)
set_env("MTMD_BACKEND_DEVICE", args.mtmd_device)
# OpenCL environment variables
set_env("GGML_OPENCL_PLATFORM", args.cl_platform)
set_env("GGML_OPENCL_OPFILTER", args.cl_opfilter)
set_env("GGML_OPENCL_KERNEL_CACHE_DIR", args.cl_cache_dir)
if args.cl_disable_fusion:
env_vars["GGML_OPENCL_DISABLE_FUSION"] = "1"
if args.cl_adreno_xmem:
env_vars["GGML_OPENCL_ADRENO_XMEM_GEMM"] = "1"
if args.cl_adreno_large_buffer:
env_vars["GGML_OPENCL_ADRENO_USE_LARGE_BUFFER"] = "1"
if args.sched_debug:
env_vars["GGML_SCHED_DEBUG"] = "2"
# Resolve executable path
executable = cmd_args[0]
known_binaries = ["llama-cli", "llama-bench", "llama-completion", "llama-mtmd-cli", "test-backend-ops"]
if executable in known_binaries:
if target_type in ("android", "linux"):
resolved_exec = f"./bin/{executable}"
else:
if platform.system() == "Windows":
resolved_exec = os.path.normpath(os.path.join(package_path, "bin", f"{executable}.exe"))
else:
resolved_exec = os.path.normpath(os.path.join(package_path, "bin", executable))
cmd_args[0] = resolved_exec
# Infer device string to pass to the tool
basename = os.path.basename(executable)
if basename.endswith(".exe"):
basename = basename[:-4]
device_val = None
if basename == "test-backend-ops":
for i in range(len(cmd_args)):
if cmd_args[i] in ("-p", "--params") and i + 1 < len(cmd_args):
val = cmd_args[i + 1]
new_val = ""
for j, char in enumerate(val):
if char in ('[', ']'):
if j > 0 and val[j - 1] == '\\':
new_val += char
else:
new_val += '\\' + char
else:
new_val += char
cmd_args[i + 1] = new_val
has_b = any(arg == "-b" for arg in cmd_args)
if not has_b:
if args.devices:
device_val = device_arg_from_devices(args.devices)
else:
device_val = "HTP0"
if device_val:
cmd_args += ["-b", device_val]
else:
has_device = any(arg.startswith("--device") for arg in cmd_args)
if not has_device:
if args.devices:
device_val = device_arg_from_devices(args.devices)
else:
device_val = "HTP0"
if device_val:
cmd_args += ["--device", device_val]
# Automatically add -v to known llama tools if sched-debug, verbose, or profile are set
verbose_trigger = (
args.sched_debug
or args.verbose is not None
or args.profile is not None
or args.hex_verbose is not None
or args.hex_profile is not None
or args.hex_optrace is not None
)
if verbose_trigger and basename in ("llama-cli", "llama-completion", "llama-bench", "llama-server", "llama-mtmd-cli"):
if "-v" not in cmd_args and "--verbose" not in cmd_args:
cmd_args.append("-v")
# Inject defaults for llama-cli, llama-completion, and llama-server if not overridden by the user
if basename in ("llama-cli", "llama-completion", "llama-server"):
if "-ngl" not in cmd_args and "--n-gpu-layers" not in cmd_args:
cmd_args += ["-ngl", "99"]
if "-fa" not in cmd_args and "--flash-attn" not in cmd_args:
cmd_args += ["-fa", "on"]
# Use ubatch-size 1024 for hexagon backend (HTP devices)
if hex_devices and basename in ("llama-cli", "llama-completion", "llama-server", "llama-bench"):
if "--ubatch-size" not in cmd_args and "-ub" not in cmd_args:
cmd_args += ["--ubatch-size", "1024"]
elif basename in ("llama-cli", "llama-completion", "llama-server"):
if "--ubatch-size" not in cmd_args and "-ub" not in cmd_args:
cmd_args += ["--ubatch-size", "1024"]
if basename in ("llama-cli", "llama-completion", "llama-server", "llama-bench"):
if "-t" not in cmd_args and "--threads" not in cmd_args:
cmd_args += ["-t", "6"]
if getattr(args, "no_embd_offload", False):
if not any("token_embd" in arg or "output.weight" in arg for arg in cmd_args):
cmd_args += ["-ot", r"^(token_embd|output)\.weight$=CPU"]
# Resolve target directory on device
target_dir = args.target_dir
if not target_dir:
target_dir = default_target_dir(target_type, target_transport, args.home_dir)
if target_transport == "adb":
adb_base = ["adb"]
if target_val: # serial
adb_base += ["-s", target_val]
if target_type == "linux":
adb_root(adb_base)
env_parts = library_env_parts("./lib")
if target_type == "linux":
env_parts.insert(0, f"HOME={args.home_dir or '/home'}")
for k, v in env_vars.items():
env_parts.append(f"{k}={v}")
env_str = " ".join(env_parts)
cmd_str = shlex_join(cmd_args)
adb_shell_cmd = f"cd {target_dir} && ulimit -c unlimited && {env_str} {cmd_str}"
full_cmd = adb_base + ["shell", adb_shell_cmd]
logger.info(f"+ {' '.join(full_cmd)}")
res = subprocess.run(full_cmd)
sys.exit(res.returncode)
elif target_type == "linux":
ssh_host = target_val
if not ssh_host:
logger.error("Error: SSH host not specified in target (e.g. use linux:user@host, lnx:user@host, or ubuntu:user@host). Cannot execute.")
sys.exit(1)
# Linux remote run via SSH
env_parts = library_env_parts("./lib")
for k, v in env_vars.items():
env_parts.append(f"{k}={v}")
env_str = " ".join(env_parts)
cmd_str = shlex_join(cmd_args)
ssh_shell_cmd = f"cd {target_dir} && ulimit -c unlimited && {env_str} {cmd_str}"
full_cmd = ["ssh", ssh_host, ssh_shell_cmd]
logger.info(f"+ {' '.join(full_cmd)}")
res = subprocess.run(full_cmd)
sys.exit(res.returncode)
elif target_type == "windows":
logger.info("Windows target execution is currently a stub.")
sys.exit(0)
else:
# Run locally
local_env = os.environ.copy()
lib_dir = os.path.normpath(os.path.join(package_path, "lib"))
if platform.system() == "Windows":
local_env["PATH"] = lib_dir + os.path.pathsep + local_env.get("PATH", "")
else:
local_env["LD_LIBRARY_PATH"] = lib_dir + os.path.pathsep + local_env.get("LD_LIBRARY_PATH", "")
for k in MANAGED_ENV_NAMES:
local_env.pop(k, None)
for name in DSP_LIBRARY_ENV_NAMES:
local_env[name] = lib_dir
for k, v in env_vars.items():
local_env[k] = v
logger.info(f"+ {shlex_join(cmd_args)}")
res = subprocess.run(cmd_args, env=local_env)
sys.exit(res.returncode)
if __name__ == "__main__":
try:
main()
except KeyboardInterrupt:
logger.info("\nInterrupted by user.")
sys.exit(130)
+62
View File
@@ -0,0 +1,62 @@
import os
from pathlib import Path
SDK_CONFIGS = (
{
"name": "Hexagon SDK",
"repo": "snapdragon-toolchain/hexagon-sdk",
"default_version": "6.7.0.0",
"parent_dir": "Hexagon_SDK",
"archive_prefix": "hexagon-sdk-v",
"markers": ("hexagon_sdk.json",),
},
{
"name": "OpenCL SDK",
"repo": "snapdragon-toolchain/opencl-sdk",
"default_version": "2.3.2",
"parent_dir": "OpenCL_SDK",
"archive_prefix": "adreno-opencl-sdk-v",
"markers": ("include/CL", "lib/OpenCL.lib"),
},
)
def is_valid_sdk(config, target_dir):
return target_dir.is_dir() and all((target_dir / marker).exists() for marker in config["markers"])
def get_hexagon_tools_dir(hexagon_dir):
tools_parent = hexagon_dir / "tools" / "HEXAGON_Tools"
if not tools_parent.is_dir():
raise RuntimeError(f"Expected Hexagon tools directory in {tools_parent}")
tools_dirs = [path for path in tools_parent.iterdir() if path.is_dir()]
if len(tools_dirs) != 1:
raise RuntimeError(f"Expected one Hexagon tools directory in {tools_parent}")
return tools_dirs[0]
def validate_windows_sdks():
hexagon_config, opencl_config = SDK_CONFIGS
hexagon_dir = os.environ.get("HEXAGON_SDK_ROOT")
tools_dir = os.environ.get("HEXAGON_TOOLS_ROOT")
opencl_dir = os.environ.get("OPENCL_SDK_ROOT")
missing = []
expected_tools_dir = None
if not hexagon_dir or not is_valid_sdk(hexagon_config, Path(hexagon_dir)):
missing.append("HEXAGON_SDK_ROOT")
else:
try:
expected_tools_dir = get_hexagon_tools_dir(Path(hexagon_dir))
except RuntimeError:
pass
if not tools_dir or not expected_tools_dir or Path(tools_dir) != expected_tools_dir:
missing.append("HEXAGON_TOOLS_ROOT")
if not opencl_dir or not is_valid_sdk(opencl_config, Path(opencl_dir)):
missing.append("OPENCL_SDK_ROOT")
if missing:
raise RuntimeError(
f"Missing or invalid Windows SDK paths: {', '.join(missing)}. "
"Run scripts/snapdragon/setup-sdk.py first."
)
+233
View File
@@ -0,0 +1,233 @@
#!/usr/bin/env python3
#
# Install Windows on Snapdragon SDKs for llama.cpp.
#
import sys
import os
import argparse
import shutil
import logging
import json
import hashlib
import tarfile
import tempfile
from pathlib import Path
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen
from sdk import SDK_CONFIGS, get_hexagon_tools_dir, is_valid_sdk
logger = logging.getLogger("setup_sdk")
DEFAULT_SDK_BASE_DIR = r"C:\Qualcomm"
def get_sdk_releases(config):
request = Request(
f"https://api.github.com/repos/{config['repo']}/releases?per_page=100",
headers={"Accept": "application/vnd.github+json", "User-Agent": "llama.cpp"},
)
try:
with urlopen(request, timeout=30) as response:
releases = json.load(response)
except (HTTPError, URLError, TimeoutError) as err:
raise RuntimeError(f"Cannot query {config['name']} releases: {err}") from err
result = []
for release in releases:
if release["draft"] or release["prerelease"]:
continue
version = release["tag_name"].removeprefix("v")
archive_name = f"{config['archive_prefix']}{version}-arm64-wos.tar.xz"
for asset in release["assets"]:
if asset["name"] != archive_name:
continue
result.append({
"version": version,
"name": asset["name"],
"url": asset["browser_download_url"],
"sha256": (asset.get("digest") or "").removeprefix("sha256:"),
})
return result
def list_sdk_releases():
for config in SDK_CONFIGS:
logger.info("%s:", config["name"])
releases = get_sdk_releases(config)
if not releases:
logger.info(" no Windows on Snapdragon releases found")
continue
for release in releases:
logger.info(" %s: %s", release["version"], release["name"])
def get_sdk_release(config, version):
version = version or config["default_version"]
version = version.removeprefix("v")
for release in get_sdk_releases(config):
if release["version"] == version:
if not release["sha256"]:
raise RuntimeError(f"{config['name']} {version} does not provide a SHA-256 digest")
return release
raise RuntimeError(
f"No Windows on Snapdragon release for {config['name']} {version}. "
"Run scripts/snapdragon/setup-sdk.py --list-sdk-releases to see available versions."
)
def sha256sum(path):
digest = hashlib.sha256()
with open(path, "rb") as file:
for chunk in iter(lambda: file.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def download_sdk(release, archive):
while True:
if archive.exists() and sha256sum(archive) == release["sha256"]:
logger.info("Using existing archive %s", archive)
return
offset = archive.stat().st_size if archive.exists() else 0
headers = {"User-Agent": "llama.cpp"}
if offset:
headers["Range"] = f"bytes={offset}-"
logger.info("Resuming download of %s at %d MiB", release["name"], offset // (1024 * 1024))
else:
logger.info("Downloading %s", release["name"])
try:
with urlopen(Request(release["url"], headers=headers), timeout=30) as response:
mode = "ab" if offset and response.status == 206 else "wb"
with open(archive, mode) as file:
shutil.copyfileobj(response, file)
except HTTPError as err:
if err.code != 416:
raise RuntimeError(f"Cannot download {release['name']}: {err}") from err
archive.unlink(missing_ok=True)
continue
except (URLError, TimeoutError) as err:
raise RuntimeError(f"Cannot download {release['name']}: {err}") from err
if sha256sum(archive) == release["sha256"]:
return
raise RuntimeError(f"SHA-256 mismatch for {archive}. Re-run the command to resume the download.")
def extract_sdk(config, archive, target_dir):
if not hasattr(tarfile, "data_filter"):
raise RuntimeError("SDK extraction requires Python 3.10.12 or later")
with tempfile.TemporaryDirectory(prefix=f".{target_dir.name}.tmp-", dir=target_dir.parent) as staging_path:
staging_dir = Path(staging_path)
with tarfile.open(archive, "r:xz") as tar:
tar.extractall(staging_dir, filter=tarfile.data_filter)
candidates = [staging_dir] + [path for path in staging_dir.iterdir() if path.is_dir()]
extracted_dirs = [path for path in candidates if is_valid_sdk(config, path)]
if len(extracted_dirs) != 1:
raise RuntimeError(f"{config['name']} archive does not contain the expected files")
extracted_dir = extracted_dirs[0]
backup_dir = None
if target_dir.exists():
backup_dir = target_dir.parent / f".{target_dir.name}.backup"
if backup_dir.exists():
raise RuntimeError(f"Cannot replace {target_dir}: backup directory {backup_dir} already exists")
target_dir.replace(backup_dir)
try:
extracted_dir.replace(target_dir)
except Exception:
if backup_dir:
backup_dir.replace(target_dir)
raise
if backup_dir:
shutil.rmtree(backup_dir)
def install_sdk(config, version, base_dir, force):
version = (version or config["default_version"]).removeprefix("v")
target_dir = base_dir / config["parent_dir"] / version
if is_valid_sdk(config, target_dir) and not force:
logger.info("Using existing %s at %s", config["name"], target_dir)
return target_dir
release = get_sdk_release(config, version)
target_dir.parent.mkdir(parents=True, exist_ok=True)
archive = target_dir.parent / release["name"]
download_sdk(release, archive)
logger.info("Extracting %s to %s", config["name"], target_dir)
extract_sdk(config, archive, target_dir)
archive.unlink(missing_ok=True)
return target_dir
def set_user_environment(values):
if os.name != "nt":
raise RuntimeError("SDK setup must run on Windows")
import winreg
with winreg.CreateKey(winreg.HKEY_CURRENT_USER, "Environment") as key:
for name, value in values.items():
winreg.SetValueEx(key, name, 0, winreg.REG_SZ, str(value))
os.environ[name] = str(value)
import ctypes
result = ctypes.c_ulong()
ctypes.windll.user32.SendMessageTimeoutW(0xffff, 0x001a, 0, "Environment", 0x0002, 5000, ctypes.byref(result))
def setup_sdks(args):
base_dir = Path(args.sdk_base_dir).expanduser().resolve()
hexagon_config, opencl_config = SDK_CONFIGS
environment = {}
if args.hexagon is not None:
hexagon_dir = install_sdk(hexagon_config, args.hexagon, base_dir, args.force)
environment["HEXAGON_SDK_ROOT"] = hexagon_dir
environment["HEXAGON_TOOLS_ROOT"] = get_hexagon_tools_dir(hexagon_dir)
if args.opencl is not None:
opencl_dir = install_sdk(opencl_config, args.opencl, base_dir, args.force)
environment["OPENCL_SDK_ROOT"] = opencl_dir
set_user_environment(environment)
logger.info("SDK environment variables were updated. Start a new terminal before building.")
def main():
logging.basicConfig(level=logging.INFO, format="%(message)s")
parser = argparse.ArgumentParser(description="Install Windows on Snapdragon SDKs for llama.cpp.")
parser.add_argument("--list-sdk-releases", action="store_true", help="List available Windows on Snapdragon SDK releases")
parser.add_argument("--sdk-base-dir", default=DEFAULT_SDK_BASE_DIR, help=r"SDK installation directory (default: C:\Qualcomm)")
parser.add_argument("--hexagon", nargs="?", const=SDK_CONFIGS[0]["default_version"], metavar="VERSION", help="Install the Hexagon SDK, optionally selecting a version")
parser.add_argument("--opencl", nargs="?", const=SDK_CONFIGS[1]["default_version"], metavar="VERSION", help="Install the OpenCL SDK, optionally selecting a version")
parser.add_argument("--force", action="store_true", help="Reinstall selected SDKs even when they already exist")
args = parser.parse_args()
if args.list_sdk_releases:
if args.sdk_base_dir != DEFAULT_SDK_BASE_DIR or args.hexagon is not None or args.opencl is not None or args.force:
parser.error("Installation options cannot be combined with --list-sdk-releases")
list_sdk_releases()
return
if args.hexagon is None and args.opencl is None:
parser.error("Select at least one SDK with --hexagon or --opencl")
if os.name != "nt":
parser.error("SDK setup must run on Windows")
setup_sdks(args)
if __name__ == "__main__":
try:
main()
except KeyboardInterrupt:
logger.info("\nInterrupted by user.")
sys.exit(130)
except RuntimeError as err:
logger.error("Error: %s", err)
sys.exit(1)