Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,9 @@ SHARPI_MODEL=models/SmolLM2-1.7B-Instruct-Q4_K_M.gguf \
dotnet run --project src/SharpInference.Server.Host -c Release
```

GPU flags mirror llama.cpp: `-g`/`--ngl`/`--n-gpu-layers` are interchangeable, and `--device <0|CUDA0|none>`
pins a single GPU (no multi-GPU split).

## Image generation

Two pipelines, auto-detected from model filename. Benchmarked on AMD Zen 4 + RTX 4070 Ti (CUDA, 4 steps,
Expand Down
1 change: 1 addition & 0 deletions SharpInference.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
<Project Path="src/SharpInference.Vulkan/SharpInference.Vulkan.csproj" />
</Folder>
<Folder Name="/tests/">
<Project Path="tests/SharpInference.Tests.Cli/SharpInference.Tests.Cli.csproj" />
<Project Path="tests/SharpInference.Tests.Core/SharpInference.Tests.Core.csproj" />
<Project Path="tests/SharpInference.Tests.ForwardPass/SharpInference.Tests.ForwardPass.csproj" />
<Project Path="tests/SharpInference.Tests.Pipeline/SharpInference.Tests.Pipeline.csproj" />
Expand Down
67 changes: 67 additions & 0 deletions src/SharpInference.Cli/GpuDevice.cs
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'.");
Comment on lines +45 to +57

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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.TryParse on 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 by int.TryParse returning false, but we can make the error message more specific). Let's refactor this parsing logic to be more readable and robust.

        // 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..];
        
        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;
}
}
56 changes: 43 additions & 13 deletions src/SharpInference.Cli/ImageCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

In RunZImage, when deviceNone is true, the main compute backend correctly falls back to CPU. However, unlike RunFlux (which explicitly logs [dim]Upscaler backend:[/] CPU (--device none)), RunZImage silently falls back to CPU for the upscaler without printing any status message. We should add a check for deviceNone or gpu == null to print a clear status message when the upscaler falls back to CPU.

                        bool forceCpu    = s.NGpuLayers == 0 || backendChoice == 

bool forceCuda = backendChoice == "cuda";
bool forceVulkan = backendChoice == "vulkan";

Expand All @@ -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)");
}
Expand All @@ -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");
}
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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)
Expand All @@ -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
{
Expand Down
47 changes: 35 additions & 12 deletions src/SharpInference.Cli/RunCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -79,11 +79,16 @@ public sealed class Settings : CommandSettings
[DefaultValue(false)]
public bool VerbosePrompt { get; init; }

[CommandOption("--n-gpu-layers|-g")]
[Description("Layers on GPU (0=CPU only, -1=all, default: 0)")]
[CommandOption("--ngl|--n-gpu-layers|--gpu-layers|-g")]
[Description("Layers on GPU (0=CPU only, -1=all, default: 0). Mirrors llama.cpp's --n-gpu-layers/--ngl.")]
[DefaultValue(0)]
public int NGpuLayers { get; init; }

[CommandOption("--device")]
[Description("GPU device to offload to: index (0,1,…), name (CUDA0, Vulkan1), or 'none' for CPU. " +
"Default: auto. Single-device only (no multi-GPU split). Mirrors llama.cpp's --device.")]
public string? Device { get; init; }

[CommandOption("-c|--ctx-size")]
[Description("Context size / max sequence length (0 = model default)")]
[DefaultValue(0)]
Expand All @@ -98,8 +103,8 @@ public sealed class Settings : CommandSettings
[Description("KV-cache element type for the CUDA backend: fp32 (default), bf16 (half the KV VRAM → ~2x context), or q8_0 (quarter → ~4x). Like llama.cpp --cache-type-k/v. Env: SHARPI_KV_DTYPE.")]
public string? KvType { get; init; }

[CommandOption("--draft-model")]
[Description("Path to a smaller draft model for speculative decoding (greedy only, requires --temp 0)")]
[CommandOption("--model-draft|--draft-model")]
[Description("Path to a smaller draft model for speculative decoding (greedy only, requires --temp 0). Mirrors llama.cpp's --model-draft.")]
public string? DraftModelPath { get; init; }

[CommandOption("--spec-lookahead|--draft-tokens")]
Expand Down Expand Up @@ -142,8 +147,8 @@ public sealed class Settings : CommandSettings
[DefaultValue(long.MinValue)]
public long PrefillDequantCacheMb { get; init; }

[CommandOption("--rep-penalty")]
[Description("Repetition penalty (1.0 = disabled, >1.0 penalizes repeated tokens, default: 1.1)")]
[CommandOption("--repeat-penalty|--rep-penalty")]
[Description("Repetition penalty (1.0 = disabled, >1.0 penalizes repeated tokens, default: 1.1). Mirrors llama.cpp's --repeat-penalty.")]
[DefaultValue(1.1f)]
public float RepPenalty { get; init; }

Expand Down Expand Up @@ -218,6 +223,24 @@ protected override int Execute(CommandContext context, Settings settings, Cancel
if (settings.MinBatchBlas > 0)
SimdKernels.MinBatchForBlas = settings.MinBatchBlas;

// Resolve --device before any GPU call (it may set CUDA_VISIBLE_DEVICES, which the CUDA
// driver only reads at first init; Vulkan takes the index explicitly below). `--device none`
// forces the CPU path, overriding --n-gpu-layers.
int gpuDeviceIndex;
bool deviceNone;
try
{
gpuDeviceIndex = GpuDevice.Resolve(settings.Device, out deviceNone);
}
catch (InvalidOperationException ex)
{
AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(ex.Message)}");
return 1;
}
if (deviceNone && settings.NGpuLayers != 0)
AnsiConsole.MarkupLine("[yellow]Note:[/] --device none overrides --ngl/-g; running on CPU.");
int effNGpuLayers = deviceNone ? 0 : settings.NGpuLayers;

// MoE expert-cache knobs are read from the environment inside the engine
// (WarmPinConfig / HybridForwardPass / slot-manager dispose). Surface them as
// CLI flags by setting the env var here — before any forward pass is built —
Expand Down Expand Up @@ -322,7 +345,7 @@ protected override int Execute(CommandContext context, Settings settings, Cancel
// chosen forward pass ships an MTP head. The actual MTP gating happens later in
// RunSinglePrompt / RunInteractive based on sp.SpecType.
IForwardPass? mtpFwd = null;
if (hp.IsHybridSsm && settings.NGpuLayers == 0)
if (hp.IsHybridSsm && effNGpuLayers == 0)
{
hybridFwd = new HybridGdnForwardPass(model, cpuBackend, hp);
if (hybridFwd.HasMtpHead) mtpFwd = hybridFwd;
Expand All @@ -331,7 +354,7 @@ protected override int Execute(CommandContext context, Settings settings, Cancel
{
// #189 dequant cache: only the pure-CPU path (no GPU offload) runs the batched
// CPU prefill that consults it; under -g it would be a wasted F32 model copy.
long dequantBytes = settings.NGpuLayers != 0
long dequantBytes = effNGpuLayers != 0
? 0
: settings.PrefillDequantCacheMb == long.MinValue
? long.MinValue // auto / SHARPI_PREFILL_DEQUANT_MB
Expand All @@ -358,7 +381,7 @@ protected override int Execute(CommandContext context, Settings settings, Cancel
}
}

int nGpuLayers = settings.NGpuLayers;
int nGpuLayers = effNGpuLayers;

// Issue #2 (MoE on hybrid GPU+CPU produced NaN/garbled output) was resolved by
// fixing the descriptor-set reuse hazard in ComputePipeline.RecordWith.
Expand Down Expand Up @@ -591,7 +614,7 @@ protected override int Execute(CommandContext context, Settings settings, Cancel
return 1;
}

var gpu = new VulkanBackend();
var gpu = new VulkanBackend(gpuDeviceIndex);
gpuBackend = gpu;
try
{
Expand Down Expand Up @@ -640,8 +663,8 @@ protected override int Execute(CommandContext context, Settings settings, Cancel
// Hybrid: N layers GPU, rest CPU
var placement = TierPlanner.Plan(model, hp, hwProfile, settings.TurboQuant,
requestedCtxSize: ctxSize);
// Override with explicit -g N if user specified it
if (settings.NGpuLayers > 0)
// Override with explicit -g/--ngl N if user specified it
if (effNGpuLayers > 0)
placement = placement with { GpuLayers = nGpuLayers, CpuLayers = hp.NumLayers - nGpuLayers };

var hfwd = new HybridForwardPass(model, gpu, hp, placement, settings.TurboQuant);
Expand Down
4 changes: 4 additions & 0 deletions src/SharpInference.Cli/SharpInference.Cli.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -52,4 +52,8 @@
<None Include="README.md" Pack="true" PackagePath="\" />
</ItemGroup>

<ItemGroup>
<InternalsVisibleTo Include="SharpInference.Tests.Cli" />
</ItemGroup>

</Project>
Loading