290 lines
16 KiB
Perl
290 lines
16 KiB
Perl
#!/usr/bin/perl
|
|
# Unit checks for sglang-deploy.pl: the pure helpers, against what they must produce.
|
|
use strict;
|
|
use warnings;
|
|
|
|
my $root = $0 =~ m{^(.*)/[^/]+$} ? "$1/.." : '..';
|
|
$root = "./$root" if $root !~ m{^/};
|
|
require "$root/sglang-deploy.pl";
|
|
|
|
my ($passed, $failed) = (0, 0);
|
|
|
|
sub is {
|
|
my ($got, $want, $label) = @_;
|
|
$got = 'undef' unless defined $got;
|
|
$want = 'undef' unless defined $want;
|
|
if ($got eq $want) {
|
|
$passed++;
|
|
print "ok $label\n";
|
|
return 1;
|
|
}
|
|
$failed++;
|
|
print "FAIL $label\n got: $got\n want: $want\n";
|
|
return 0;
|
|
}
|
|
|
|
sub check {
|
|
my ($cond, $label) = @_;
|
|
return is($cond ? 1 : 0, 1, $label);
|
|
}
|
|
|
|
# The verdict of a match is forced into a boolean: in a sub argument list a failed match
|
|
# yields the empty list, which shifts the label into the condition slot and turns the
|
|
# failure into a silent pass.
|
|
sub like {
|
|
my ($got, $re, $label) = @_;
|
|
$label = 'like without a label' unless defined $label;
|
|
my $matched = (defined $got && $got =~ $re) ? 1 : 0;
|
|
return is($matched, 1, $label);
|
|
}
|
|
|
|
sub check_unlike {
|
|
my ($got, $re, $label) = @_;
|
|
$label = 'check_unlike without a label' unless defined $label;
|
|
my $matched = (defined $got && $got !~ $re) ? 1 : 0;
|
|
return is($matched, 1, $label);
|
|
}
|
|
|
|
# ---- version_cmp ----------------------------------------------------------
|
|
is(version_cmp('7.2.4', '7.2.0'), 1, 'version_cmp 7.2.4 > 7.2.0');
|
|
is(version_cmp('7.2.0', '7.2.4'), -1, 'version_cmp 7.2.0 < 7.2.4');
|
|
is(version_cmp('7.2', '7.2.0'), 0, 'version_cmp 7.2 == 7.2.0');
|
|
is(version_cmp('10.0.0', '7.2.4'), 1, 'version_cmp 10.0.0 > 7.2.4');
|
|
is(version_cmp('6.4.3', '7.0.0'), -1, 'version_cmp 6.4.3 < 7.0.0');
|
|
is(version_cmp('7.10.0', '7.9.0'), 1, 'version_cmp 7.10.0 > 7.9.0 (numeric, not lexical)');
|
|
|
|
# ---- select_rocm_flavour --------------------------------------------------
|
|
is(select_rocm_flavour('7.2.4'), 'rocm724', 'flavour: host 7.2.4');
|
|
is(select_rocm_flavour('7.2.5'), 'rocm724', 'flavour: host 7.2.5');
|
|
is(select_rocm_flavour('7.2.3'), 'rocm720', 'flavour: host 7.2.3');
|
|
is(select_rocm_flavour('7.2.0'), 'rocm720', 'flavour: host 7.2.0');
|
|
is(select_rocm_flavour('7.1.0'), 'rocm700', 'flavour: host 7.1.0 falls back to rocm700');
|
|
is(select_rocm_flavour('7.0.0'), 'rocm700', 'flavour: host 7.0.0');
|
|
is(select_rocm_flavour('10.0.0'), 'rocm10', 'flavour: host 10.0.0');
|
|
is(select_rocm_flavour('10.2.0'), 'rocm10', 'flavour: host 10.2.0');
|
|
is(select_rocm_flavour('6.4.0'), undef, 'flavour: host 6.4.0 has no image');
|
|
|
|
# ---- arch_variant / radeon_present ---------------------------------------
|
|
my @mi300 = ('Advanced Micro Devices, Inc. [AMD/ATI] Instinct MI300X OAM [1002:74a1] (rev 01)');
|
|
my @mi325 = ('Advanced Micro Devices, Inc. [AMD/ATI] Instinct MI325X [1002:74a1]');
|
|
my @mi350 = ('Advanced Micro Devices, Inc. [AMD/ATI] Instinct MI350X [1002:75a0]');
|
|
my @mi355 = ('Advanced Micro Devices, Inc. [AMD/ATI] Instinct MI355X [1002:75a3]');
|
|
my @radeon = ('Advanced Micro Devices, Inc. [AMD/ATI] Navi 31 [Radeon RX 7900 XTX] [1002:744c]');
|
|
my @strix = ('Advanced Micro Devices, Inc. [AMD/ATI] Strix Halo [Radeon 8060S] [1002:150e]');
|
|
is(arch_variant(\@mi300), 'mi30x', 'arch: MI300X -> mi30x');
|
|
is(arch_variant(\@mi325), 'mi30x', 'arch: MI325X -> mi30x');
|
|
is(arch_variant(\@mi350), 'mi35x', 'arch: MI350X -> mi35x');
|
|
is(arch_variant(\@mi355), 'mi35x', 'arch: MI355X -> mi35x');
|
|
is(arch_variant(\@radeon), undef, 'arch: Radeon has no published image');
|
|
is(arch_variant(\@strix), 'gfx1151', 'arch: Strix Halo maps to gfx1151');
|
|
is(radeon_present(\@radeon), 1, 'radeon: Navi 31 detected');
|
|
is(radeon_present(\@strix), 0, 'radeon: Strix Halo is served by AMD builds');
|
|
is(radeon_present(\@mi300), 0, 'radeon: MI300X is not a Radeon card');
|
|
|
|
# ---- resolve_image / model_display ---------------------------------------
|
|
is(resolve_image('docker.io/lmsysorg/sglang', 'v0.5.19', 'mi30x', 'rocm10'),
|
|
'docker.io/lmsysorg/sglang:v0.5.19-rocm10-mi30x', 'image: resolved tag');
|
|
is(resolve_image('docker.io/rocm/sgl-dev', 'v0.5.19', 'gfx1151', 'rocm724'),
|
|
'docker.io/rocm/sgl-dev:v0.5.19-rocm724-gfx1151', 'image: AMD dev repository for gfx1151');
|
|
is(model_display('ZhipuAI/GLM-5.3'), 'GLM 5.3', 'display: known model');
|
|
is(model_display('acme/unknown'), 'acme/unknown', 'display: unknown model passes through');
|
|
|
|
# ---- the preset catalogue ------------------------------------------------
|
|
# The menu is the owner's list in the owner's order, so the names are asserted
|
|
# one by one, and each display name must resolve to the exact ModelScope
|
|
# repository the engine will pull weights from.
|
|
is(join('|', default_model_order()),
|
|
'GLM 5.3|GLM 5.3 Flash|Qwen 3.8 Max|Qwen 3.8 Flash|DeepSeek V4.1 Flash',
|
|
'presets: the menu order');
|
|
is(model_display('ZhipuAI/GLM-5.3'), 'GLM 5.3', 'presets: GLM 5.3');
|
|
is(default_model_repo('GLM 5.3'), 'ZhipuAI/GLM-5.3', 'presets: GLM 5.3 on ModelScope');
|
|
is(default_model_repo('GLM 5.3 Flash'), 'ZhipuAI/GLM-5.3-Flash',
|
|
'presets: GLM 5.3 Flash on ModelScope, under ZhipuAI');
|
|
is(default_model_repo('Qwen 3.8 Max'), 'Qwen/Qwen3.8-2.4T-A95B',
|
|
'presets: Qwen 3.8 Max under its weight name');
|
|
is(default_model_repo('Qwen 3.8 Flash'), 'Qwen/Qwen3.8-Flash-Next',
|
|
'presets: Qwen 3.8 Flash under its weight name');
|
|
is(default_model_repo('DeepSeek V4.1 Flash'), 'deepseek-ai/DeepSeek-V4.1-Flash',
|
|
'presets: DeepSeek V4.1 Flash');
|
|
is(model_display('Qwen/Qwen3.8-2.4T-A95B'), 'Qwen 3.8 Max',
|
|
'display: the reverse lookup through a weight name');
|
|
for my $id (map { default_model_repo($_) } default_model_order()) {
|
|
like($id, qr{^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$}, "presets: $id fits the model ID pattern");
|
|
}
|
|
|
|
# ---- base64url and key shape ---------------------------------------------
|
|
is(encode_base64url('Man'), 'TWFu', 'base64url: Man -> TWFu');
|
|
is(encode_base64url("\xfb\xff"), '-_8', 'base64url: FB FF -> -_8 (url alphabet)');
|
|
is(encode_base64url('M'), 'TQ', 'base64url: one byte, no padding');
|
|
my $key = generate_api_key();
|
|
like($key, qr/^[A-Za-z0-9_-]{43}$/, 'key: 32 bytes -> 43 url-safe characters');
|
|
my $key2 = generate_api_key();
|
|
is($key eq $key2 ? 'same' : 'different', 'different', 'key: two calls differ');
|
|
|
|
# ---- is_number ------------------------------------------------------------
|
|
is((is_number('8000') ? 1 : 0), 1, 'is_number: 8000');
|
|
is((is_number('0.90') ? 1 : 0), 1, 'is_number: 0.90');
|
|
is((is_number('abc') ? 1 : 0), 0, 'is_number: abc');
|
|
is((is_number('') ? 1 : 0), 0, 'is_number: empty');
|
|
|
|
# ---- is_positive_int / valid_dir_path -------------------------------------
|
|
is((is_positive_int('4') ? 1 : 0), 1, 'is_positive_int: 4');
|
|
is((is_positive_int('0') ? 1 : 0), 0, 'is_positive_int: 0 is not positive');
|
|
is((is_positive_int('8000.5') ? 1 : 0), 0, 'is_positive_int: a fractional port is refused');
|
|
is((is_positive_int('1e2') ? 1 : 0), 0, 'is_positive_int: an exponent is refused');
|
|
is((valid_dir_path('/opt/sglang') ? 1 : 0), 1, 'valid_dir_path: an absolute path passes');
|
|
is((valid_dir_path('sglang') ? 1 : 0), 0, 'valid_dir_path: a relative path is refused');
|
|
is((valid_dir_path('/opt/sg lang') ? 1 : 0), 0, 'valid_dir_path: whitespace is refused');
|
|
is((valid_dir_path('/opt/%h/sglang') ? 1 : 0), 0,
|
|
'valid_dir_path: a systemd specifier is refused');
|
|
is((valid_dir_path('/opt/x;/sglang') ? 1 : 0), 0,
|
|
'valid_dir_path: a path with a semicolon is refused');
|
|
|
|
# ---- run(): exit status, signals and timeout ------------------------------
|
|
my $killed = run([$^X, '-e', 'kill 9, $$']);
|
|
is($killed->{rc}, 137, 'run: a child killed by a signal reports 128+signal, not 0');
|
|
my $exited = run([$^X, '-e', 'exit 3']);
|
|
is($exited->{rc}, 3, 'run: the child exit code passes through');
|
|
my $timedout = run([$^X, '-e', 'sleep 5'], timeout => 1);
|
|
is($timedout->{rc}, 124, 'run: an expired timeout reports rc 124');
|
|
|
|
# ---- family_is_fatal: --image overrides the GPU family --------------------
|
|
is(family_is_fatal(undef, 0), 1, 'family: an unmatched card without --image is fatal');
|
|
is(family_is_fatal(undef, 1), 0, 'family: --image overrides an unmatched card');
|
|
is(family_is_fatal('mi30x', 0), 0, 'family: a published family is never fatal');
|
|
|
|
# ---- radeon_env_for: the family decides, not any Radeon -------------------
|
|
my @mixed = ('Instinct MI300X [1002:74a1]', 'Navi 31 [Radeon RX 7900 XTX] [1002:744c]');
|
|
is(arch_variant(\@mixed), 'mi30x', 'env: a mixed host resolves the Instinct family');
|
|
is(radeon_present(\@mixed), 1, 'env: a mixed host does carry a Radeon');
|
|
is(scalar @{ radeon_env_for(arch_variant(\@mixed), radeon_present(\@mixed)) }, 0,
|
|
'env: a display Radeon does not switch an Instinct deployment to the Radeon defaults');
|
|
is(scalar @{ radeon_env_for('gfx1151', 0) }, 2, 'env: gfx1151 carries the Radeon defaults');
|
|
is(scalar @{ radeon_env_for(undef, 1) }, 2,
|
|
'env: a custom image on a Radeon-only host carries the Radeon defaults');
|
|
is(scalar @{ radeon_env_for('mi30x', 0) }, 0, 'env: an Instinct host carries none');
|
|
|
|
# ---- caddy drop-in --------------------------------------------------------
|
|
my $conf = caddyfile_content(8000, '[::1]', '/etc/ssl/sglang');
|
|
like($conf, qr/^:443 \{$/m, 'caddy: the endpoint site on 443');
|
|
like($conf, qr|tls /etc/ssl/sglang/sglang\.crt /etc/ssl/sglang/sglang\.key|,
|
|
'caddy: certificate pair paths');
|
|
like($conf, qr/reverse_proxy \[::1\]:8000 \{/, 'caddy: loopback upstream with the port');
|
|
like($conf, qr/flush_interval -1/, 'caddy: unbuffered streaming');
|
|
like($conf, qr/header_up X-Real-IP \{remote_host\}/, 'caddy: X-Real-IP survives the heredoc');
|
|
like($conf, qr/max_size 50MB/, 'caddy: the request body limit');
|
|
like($conf, qr/\treverse_proxy /, 'caddy: tab-indented as the Caddyfile is formatted');
|
|
check_unlike($conf, qr/\bbind\b/, 'caddy: no bind directive, the kernel decides the families');
|
|
check_unlike($conf, qr/acme|on_demand|http:\/\/\{/i, 'caddy: no ACME, the self-signed pair is used');
|
|
|
|
my $conf4 = caddyfile_content(9000, '127.0.0.1', '/etc/ssl/llm');
|
|
like($conf4, qr/reverse_proxy 127\.0\.0\.1:9000 \{/, 'caddy: an IPv4 upstream carries the port');
|
|
like($conf4, qr|tls /etc/ssl/llm/sglang\.crt|, 'caddy: the certificate directory follows --cert-dir');
|
|
is(caddyfile_path('sglang'), '/etc/caddy/Caddyfile.d/sglang.caddyfile',
|
|
'caddy: the drop-in path follows the service name');
|
|
is(caddy_main_config(), '/etc/caddy/Caddyfile', 'caddy: the main Caddyfile path');
|
|
|
|
# ---- caddy release binary -------------------------------------------------
|
|
is(uname_to_arch('x86_64'), 'amd64', 'caddy binary: x86_64 maps to amd64');
|
|
is(uname_to_arch('aarch64'), 'arm64', 'caddy binary: aarch64 maps to arm64');
|
|
is(uname_to_arch('ppc64le'), undef, 'caddy binary: an unmapped machine is refused');
|
|
is(caddy_asset_url('2.10.2', 'amd64'),
|
|
'https://github.com/caddyserver/caddy/releases/download/v2.10.2/caddy_2.10.2_linux_amd64.tar.gz',
|
|
'caddy binary: the release asset URL');
|
|
|
|
my $caddy_unit = caddy_unit_content();
|
|
like($caddy_unit, qr|ExecStartPre=/usr/local/bin/caddy validate --config /etc/caddy/Caddyfile|,
|
|
'caddy binary: the unit validates before it starts');
|
|
like($caddy_unit, qr|ExecStart=/usr/local/bin/caddy run --environ --config /etc/caddy/Caddyfile|,
|
|
'caddy binary: the unit runs the release binary');
|
|
like($caddy_unit, qr|ExecReload=/usr/local/bin/caddy reload --config /etc/caddy/Caddyfile|,
|
|
'caddy binary: the unit reloads through the admin endpoint');
|
|
like($caddy_unit, qr/^User=caddy$/m, 'caddy binary: the unit runs as the caddy user');
|
|
like($caddy_unit, qr/AmbientCapabilities=CAP_NET_BIND_SERVICE/, 'caddy binary: the port capability');
|
|
|
|
# ---- systemd unit --------------------------------------------------------
|
|
my $unit = systemd_content({
|
|
model => 'ZhipuAI/GLM-5.3',
|
|
host => '::1',
|
|
port => 8000,
|
|
tensor_parallel => 4,
|
|
max_model_len => 32768,
|
|
gpu_memory_utilization => '0.9',
|
|
state_dir => '/opt/sglang',
|
|
image => 'docker.io/lmsysorg/sglang:v0.5.19-rocm10-mi30x',
|
|
env_file => '/etc/sysconfig/sglang',
|
|
radeon_env => [],
|
|
});
|
|
like($unit, qr/Description=SGLang Inference Server \(ZhipuAI\/GLM-5\.3\)/, 'unit: description');
|
|
like($unit, qr/EnvironmentFile=\/etc\/sysconfig\/sglang/, 'unit: EnvironmentFile');
|
|
like($unit, qr/Environment=SGLANG_USE_MODELSCOPE=true/, 'unit: weights come from ModelScope');
|
|
like($unit, qr/--network=host/, 'unit: host networking');
|
|
like($unit, qr/--device=\/dev\/kfd --device=\/dev\/dri/, 'unit: GPU device nodes');
|
|
like($unit, qr/--group-add video/, 'unit: video group');
|
|
like($unit, qr/--ipc=host/, 'unit: IPC namespace');
|
|
like($unit, qr/--cap-add=SYS_PTRACE/, 'unit: ptrace capability');
|
|
like($unit, qr/--security-opt seccomp=unconfined/, 'unit: seccomp');
|
|
like($unit, qr|--volume /opt/sglang/modelscope:/root/\.cache/modelscope:Z|,
|
|
'unit: ModelScope cache volume with SELinux relabel');
|
|
like($unit, qr/--env MODELSCOPE_TOKEN/, 'unit: ModelScope token forwarded when set');
|
|
like($unit, qr/--rm --replace --name sglang/, 'unit: container name and cleanup');
|
|
like($unit, qr/--pull=missing/, 'unit: pull policy');
|
|
like($unit, qr/docker\.io\/lmsysorg\/sglang:v0\.5\.19-rocm10-mi30x/, 'unit: image');
|
|
like($unit, qr/python3 -m sglang\.launch_server/, 'unit: engine entry point');
|
|
like($unit, qr/--model-path ZhipuAI\/GLM-5\.3/, 'unit: model path');
|
|
like($unit, qr/--host ::1/, 'unit: loopback bind');
|
|
like($unit, qr/--port 8000/, 'unit: port');
|
|
like($unit, qr/--api-key \$\{SGLANG_API_KEY\}/, 'unit: API key from the environment file');
|
|
is($unit =~ /sk-[A-Za-z0-9]/ ? 'yes' : 'no', 'no', 'unit: no literal API key');
|
|
like($unit, qr/--tp-size 4/, 'unit: tensor parallel as --tp-size');
|
|
like($unit, qr/--context-length 32768/, 'unit: context length');
|
|
like($unit, qr/--mem-fraction-static 0\.9/, 'unit: static memory fraction');
|
|
is($unit =~ /SGLANG_USE_AITER/ ? 'yes' : 'no', 'no',
|
|
'unit: no Radeon variables on a non-Radeon host');
|
|
like($unit, qr/WantedBy=multi-user\.target/, 'unit: install section');
|
|
like($unit, qr/Restart=on-failure/, 'unit: restart policy');
|
|
|
|
my $unit_radeon = systemd_content({
|
|
model => 'ZhipuAI/GLM-5.3',
|
|
host => '::1',
|
|
port => 8000,
|
|
tensor_parallel => 1,
|
|
max_model_len => 4096,
|
|
gpu_memory_utilization => '0.9',
|
|
state_dir => '/opt/sglang',
|
|
image => 'local/sglang-rocm:latest',
|
|
env_file => '/etc/sysconfig/sglang',
|
|
radeon_env => ['SGLANG_USE_AITER=false', 'SGLANG_ROCM_FUSED_DECODE_MLA=false'],
|
|
});
|
|
like($unit_radeon, qr/Environment=SGLANG_USE_AITER=false/, 'unit: AITER off on Radeon');
|
|
like($unit_radeon, qr/Environment=SGLANG_ROCM_FUSED_DECODE_MLA=false/,
|
|
'unit: fused decode MLA off on Radeon');
|
|
|
|
# ---- parse_os_release reads the host (smoke test) ------------------------
|
|
my %release = parse_os_release();
|
|
is(exists $release{ID} ? 'present' : 'absent', 'present', 'os-release: ID is parsed');
|
|
|
|
# ---- arguments -----------------------------------------------------------
|
|
my @ARGV_COPY = @ARGV;
|
|
local @ARGV = ('--model', 'ZhipuAI/GLM-5.3', '--port=9000', '--tensor-parallel', '2');
|
|
my %args = parse_args();
|
|
is($args{model}, 'ZhipuAI/GLM-5.3', 'args: --model value');
|
|
is($args{port}, '9000', 'args: --port=9000 inline form');
|
|
is($args{tensor_parallel}, '2', 'args: --tensor-parallel value');
|
|
is($args{service_name}, 'sglang', 'args: service name default');
|
|
is($args{state_dir}, '/opt/sglang', 'args: state dir default');
|
|
is($args{cert_dir}, '/etc/ssl/sglang', 'args: cert dir default');
|
|
|
|
@ARGV = ('--dry-run', '--uninstall', '--rocm-flavour', 'rocm720');
|
|
my %args2 = parse_args();
|
|
is($args2{dry_run}, 1, 'args: --dry-run');
|
|
is($args2{uninstall}, 1, 'args: --uninstall');
|
|
is($args2{rocm_flavour}, 'rocm720', 'args: --rocm-flavour');
|
|
|
|
@ARGV = @ARGV_COPY;
|
|
|
|
print "\n" . ($failed
|
|
? "$failed FAILED of " . ($passed + $failed)
|
|
: "all $passed checks passed") . "\n";
|
|
exit($failed ? 1 : 0);
|