include llama.cpp since there were some edits
This commit is contained in:
1 parent
db7f28d087
commit
8c66990853
3918 files changed
+1640170
-1
No files matched your search
@@ -0,0 +1 @@
|
||||
0xffff
|
||||
Executable
+426
@@ -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
@@ -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()
|
||||
+1525
File diff suppressed because it is too large.
Load diff
+901
@@ -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
@@ -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,
|
||||
)
|
||||
Executable
+569
@@ -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)
|
||||
@@ -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."
|
||||
)
|
||||
@@ -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)
|
||||
Reference in new issue
Block a user