-
Notifications
You must be signed in to change notification settings - Fork 0
feat(cli): llama.cpp-compatible --ngl/--device GPU selection flags #244
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,67 @@ | ||
| namespace SharpInference.Cli; | ||
|
|
||
| /// <summary> | ||
| /// Parses the llama.cpp-style <c>-dev/--device</c> option into a single GPU selection and | ||
| /// applies it. Unlike llama.cpp's comma-separated multi-GPU list, SharpInference targets a | ||
| /// single device (it has no tensor/row split), so only one device may be named. | ||
| /// | ||
| /// Accepted values: | ||
| /// <list type="bullet"> | ||
| /// <item><c>null</c> / empty / <c>auto</c> — auto-select (returns index -1, no change)</item> | ||
| /// <item><c>none</c> / <c>cpu</c> — don't offload (sets <paramref name="none"/> = true)</item> | ||
| /// <item>a bare index — <c>0</c>, <c>1</c>, …</item> | ||
| /// <item>a named device with a trailing index — <c>CUDA0</c>, <c>Vulkan1</c>, <c>GPU2</c></item> | ||
| /// </list> | ||
| /// | ||
| /// When a concrete index is selected it pins the CUDA runtime to that physical GPU via | ||
| /// <c>CUDA_VISIBLE_DEVICES</c> (honored process-wide by every CUDA host thread, unlike a | ||
| /// per-thread <c>cudaSetDevice</c>) and returns the same index for the Vulkan physical-device | ||
| /// selector. The CUDA env var is only set when the caller has not already constrained the | ||
| /// visible set themselves. | ||
| /// </summary> | ||
| internal static class GpuDevice | ||
| { | ||
| public static int Resolve(string? device, out bool none) | ||
| { | ||
| none = false; | ||
| if (string.IsNullOrWhiteSpace(device)) | ||
| return -1; | ||
|
|
||
| var value = device.Trim(); | ||
| if (value.Equals("auto", StringComparison.OrdinalIgnoreCase)) | ||
| return -1; | ||
| if (value.Equals("none", StringComparison.OrdinalIgnoreCase) || | ||
| value.Equals("cpu", StringComparison.OrdinalIgnoreCase)) | ||
| { | ||
| none = true; | ||
| return -1; | ||
| } | ||
|
|
||
| if (value.Contains(',')) | ||
| throw new InvalidOperationException( | ||
| $"--device '{device}': multi-device split is not supported; specify a single device " + | ||
| "(e.g. 0, CUDA0, Vulkan1, or 'none' for CPU)."); | ||
|
|
||
| // Strip an optional leading backend name (CUDA/Vulkan/GPU/…) and read the trailing index. | ||
| int i = value.Length; | ||
| while (i > 0 && char.IsDigit(value[i - 1])) i--; | ||
| var digits = value[i..]; | ||
| // The part before the index must be empty or a plain backend name (letters only) — | ||
| // this rejects things like "-1" (which would otherwise parse as device 1). | ||
| bool prefixOk = true; | ||
| for (int j = 0; j < i; j++) | ||
| if (!char.IsLetter(value[j])) { prefixOk = false; break; } | ||
| if (!prefixOk || digits.Length == 0 || !int.TryParse(digits, out int index) || index < 0) | ||
| throw new InvalidOperationException( | ||
| $"--device '{device}': expected a device index (0, 1, …), a named device " + | ||
| "(CUDA0, Vulkan1), 'auto', or 'none'."); | ||
|
|
||
| // Pin CUDA to this physical device process-wide. Harmless on the Vulkan path (Vulkan | ||
| // enumerates all devices regardless and uses the returned index). Don't override an | ||
| // explicit CUDA_VISIBLE_DEVICES the user already set in the environment. | ||
| if (Environment.GetEnvironmentVariable("CUDA_VISIBLE_DEVICES") is null) | ||
| Environment.SetEnvironmentVariable("CUDA_VISIBLE_DEVICES", index.ToString()); | ||
|
|
||
| return index; | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -67,11 +67,15 @@ public sealed class Settings : CommandSettings | |
| [Description("(Z-Image) Path to Qwen3 tokenizer.json")] | ||
| public string? QwenTokenizerPath { get; init; } | ||
|
|
||
| [CommandOption("-g|--n-gpu-layers")] | ||
| [CommandOption("--ngl|--n-gpu-layers|--gpu-layers|-g")] | ||
| [Description("(Z-Image) GPU acceleration: -1 = auto (CUDA→Vulkan→CPU, default), 0 = CPU only")] | ||
| [DefaultValue(-1)] | ||
| public int NGpuLayers { get; init; } | ||
|
|
||
| [CommandOption("--device")] | ||
| [Description("(Z-Image) GPU to use: auto (default), none/cpu, an index (0, 1), or a named device (CUDA0, Vulkan1)")] | ||
| public string? Device { get; init; } | ||
|
|
||
| [CommandOption("--backend")] | ||
| [Description("(Z-Image) Force compute backend: auto (default), cuda, vulkan, cpu")] | ||
| public string? Backend { get; init; } | ||
|
|
@@ -181,10 +185,21 @@ private static int RunNative(Settings s) | |
| return 1; | ||
| } | ||
|
|
||
| return IsZImage(modelPath) ? RunZImage(s, modelPath) : RunFlux(s, modelPath); | ||
| int deviceIndex; | ||
| bool deviceNone; | ||
| try { deviceIndex = GpuDevice.Resolve(s.Device, out deviceNone); } | ||
| catch (InvalidOperationException ex) | ||
| { | ||
| AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(ex.Message)}"); | ||
| return 1; | ||
| } | ||
|
|
||
| return IsZImage(modelPath) | ||
| ? RunZImage(s, modelPath, deviceIndex, deviceNone) | ||
| : RunFlux(s, modelPath, deviceIndex, deviceNone); | ||
| } | ||
|
|
||
| private static int RunZImage(Settings s, string modelPath) | ||
| private static int RunZImage(Settings s, string modelPath, int deviceIndex, bool deviceNone) | ||
| { | ||
| // Auto-discover component paths from models/ directory if not explicitly provided | ||
| string? vaePath = s.VaePath ?? ResolveZImageVae(); | ||
|
|
@@ -229,7 +244,9 @@ private static int RunZImage(Settings s, string modelPath) | |
| // -g 0 → CPU only | ||
| // default (-1) → CUDA → Vulkan → CPU fallback | ||
| string backendChoice = (s.Backend ?? "auto").ToLowerInvariant(); | ||
| bool forceCpu = s.NGpuLayers == 0 || backendChoice == "cpu"; | ||
| if (deviceNone && (backendChoice is "cuda" or "vulkan")) | ||
| AnsiConsole.MarkupLine("[yellow]Note:[/] --device none overrides --backend; running on CPU."); | ||
| bool forceCpu = s.NGpuLayers == 0 || backendChoice == "cpu" || deviceNone; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. In bool forceCpu = s.NGpuLayers == 0 || backendChoice == |
||
| bool forceCuda = backendChoice == "cuda"; | ||
| bool forceVulkan = backendChoice == "vulkan"; | ||
|
|
||
|
|
@@ -244,12 +261,14 @@ private static int RunZImage(Settings s, string modelPath) | |
| { | ||
| try | ||
| { | ||
| var vulkan = new VulkanBackend(); | ||
| var vulkan = new VulkanBackend(deviceIndex); | ||
| gpu = vulkan; | ||
| vulkan.PrintDeviceInfo(); | ||
| AnsiConsole.MarkupLine("[dim]Backend:[/] GPU (Vulkan SGEMM)"); | ||
| } | ||
| catch | ||
| // Only auto-select (deviceIndex < 0) silently falls back to CPU. | ||
| // An explicit --device must surface its error, not vanish into a CPU run. | ||
| catch when (deviceIndex < 0) | ||
| { | ||
| AnsiConsole.MarkupLine("[dim]Backend:[/] CPU (no GPU detected)"); | ||
| } | ||
|
|
@@ -275,15 +294,19 @@ private static int RunZImage(Settings s, string modelPath) | |
| { | ||
| try | ||
| { | ||
| upscalerGpu = new VulkanBackend(); | ||
| upscalerGpu = new VulkanBackend(deviceIndex); | ||
| upscalerBackend = upscalerGpu; | ||
| AnsiConsole.MarkupLine("[dim]Upscaler backend:[/] GPU (Vulkan, native kernels)"); | ||
| } | ||
| catch | ||
| catch when (deviceIndex < 0) | ||
| { | ||
| upscalerBackend = gpu; // fallback: SGEMM on main backend | ||
| upscalerBackend = gpu; // auto only: fall back to SGEMM on main backend | ||
| } | ||
| } | ||
| if (upscalerBackend is null) | ||
| AnsiConsole.MarkupLine(deviceNone | ||
| ? "[dim]Upscaler backend:[/] CPU (--device none)" | ||
| : "[dim]Upscaler backend:[/] CPU"); | ||
| upscaler = RRDBNet.Load(s.UpscalerPath, upscalerBackend); | ||
| AnsiConsole.MarkupLine($"[dim]Upscaler:[/] RRDBNet ×{upscaler.Scale} loaded"); | ||
| } | ||
|
|
@@ -330,7 +353,7 @@ private static int RunZImage(Settings s, string modelPath) | |
| return 0; | ||
| } | ||
|
|
||
| private static int RunFlux(Settings s, string modelPath) | ||
| private static int RunFlux(Settings s, string modelPath, int deviceIndex, bool deviceNone) | ||
| { | ||
| if (!RequireFile(s.VaePath, "--vae", "ae.safetensors")) return 1; | ||
| if (!RequireFile(s.ClipLPath, "--clip-l", "clip_l.safetensors")) return 1; | ||
|
|
@@ -376,9 +399,14 @@ private static int RunFlux(Settings s, string modelPath) | |
| { | ||
| // Prefer CudaBackend directly when NVRTC image kernels are available. | ||
| // Fall back to VulkanBackend, then CudaBackend SGEMM path. | ||
| // --device none/cpu (deviceNone) skips the GPU upscaler entirely. | ||
| try | ||
| { | ||
| if (CudaBackend.IsAvailable()) | ||
| if (deviceNone) | ||
| { | ||
| AnsiConsole.MarkupLine("[dim]Upscaler backend:[/] CPU (--device none)"); | ||
| } | ||
| else if (CudaBackend.IsAvailable()) | ||
| { | ||
| var cudaForUpscaler = CudaBackend.Create(); | ||
| if (cudaForUpscaler.ImageKernelsAvailable) | ||
|
|
@@ -401,10 +429,12 @@ private static int RunFlux(Settings s, string modelPath) | |
| { | ||
| try | ||
| { | ||
| upscalerGpu = new VulkanBackend(); | ||
| upscalerGpu = new VulkanBackend(deviceIndex); | ||
| AnsiConsole.MarkupLine("[dim]Upscaler backend:[/] GPU (Vulkan, native kernels)"); | ||
| } | ||
| catch | ||
| // An explicit --device error propagates to the outer handler (surfaced, | ||
| // not swallowed into a wrong-device or silent-CPU fallback). | ||
| catch when (deviceIndex < 0) | ||
| { | ||
| try | ||
| { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The logic to parse the trailing index and validate the prefix can be simplified and made more robust. Currently, the loop checks if each character in the prefix is a letter, but we can use
int.TryParseon the trailing digits and check if the remaining prefix is entirely alphabetic more cleanly. Additionally, we should ensure that we handle cases where there are no digits at all or if the index parsing fails due to overflow (which is already handled byint.TryParsereturning false, but we can make the error message more specific). Let's refactor this parsing logic to be more readable and robust.