feat(gpu-service): support sliced (by-ratio) GPU instances and PV status

- Add whole/by-ratio segmented toggle to the GPU Instance form; sliced
  mode picks a VRAM ratio (10-100 or finer 1-10 when max<10), scales
  CPU/RAM by the ratio (floored, min 1), pins compute at 100%
- Availability, card Max, and Instance Type cell reflect sliceable types
- Add ready/deleting status columns for storage and storage types
This commit is contained in:
jialin
2026-07-22 15:52:16 +08:00
parent 730cbc9b64
commit 63ed6e777b
15 changed files with 514 additions and 47 deletions
+32
View File
@@ -97,6 +97,38 @@ const handleBChange = (b) => {
};
```
## 4. Controlled input with derived fields
When a controlled field's value comes from **both** user input and a programmatic default (e.g. a percentage picked on a slider, and a default seeded on select / mode-switch), funnel both through **one commit function** — don't duplicate "write field + recompute derived" per call site.
- The `Form.Item`-bound input's `onChange(value)` forwards the value to the commit fn (the field is antd-bound, but pass the value explicitly so the default path can reuse the same fn instead of reading the store).
- Seed defaults by calling the **same** commit fn with the computed value.
- Separate the **commit action** (write field + recompute dependents) from the **render-derive** (read the field → recompute dependents). Keeping the derive standalone lets it re-run on reload/edit where there's no user event.
```ts
// commit action — slider onChange AND default both call this
const commitRatio = (value: number) => {
form.setFieldsValue({ spec: { resources: { ratio: value, cores: 100 } } });
rescaleDerived(); // reads ratio from the form, sets the disabled cpu/ram
};
// render-derive — also called from the edit/reload effect
const rescaleDerived = () => {
const ratio = form.getFieldValue(['spec', 'resources', 'ratio']);
form.setFieldsValue({
spec: {
resources: {
cpu: floorScale(unit.cpu, ratio),
ram: floorScale(unit.ram, ratio)
}
}
});
};
// default seeding reuses the commit fn — one path, not a second copy
const applyDefaults = (item) => commitRatio(Math.min(10, item.maxRatio) || 10);
```
## Related
- Module/file structure for forms lives in the **create-crud-page** skill (section 3).
+9
View File
@@ -119,8 +119,17 @@ export default {
'No available GPU resources, please choose another instance type.',
'gpuservice.instance.gpuCount.zero':
'CPU-only setup for environment preparation.',
'gpuservice.instance.mode.whole': 'Full GPU',
'gpuservice.instance.mode.sliced': 'By Ratio',
'gpuservice.instance.slice.memoryPercentage': 'VRAM Percentage (%)',
'gpuservice.instance.slice.fullCores': '100% Compute',
'gpuservice.instance.slice.percentage.required':
'Please select or enter a percentage',
'gpuservice.instance.slice.percentage.max':
'The ratio must be between 1% and {count}%',
'gpuservice.instance.stock': 'Stock',
'gpuservice.instance.sliced': 'Sliced',
'gpuservice.instance.sliceable': 'Sliceable',
'gpuservice.instance.memory': 'VRAM',
'gpuservice.instance.ram': 'RAM',
'gpuservice.instance.os': 'OS',
+9
View File
@@ -118,8 +118,17 @@ export default {
'gpuservice.instance.gpuCount.noAvailable':
'利用可能な GPU リソースがありません。別のインスタンスタイプを選択してください。',
'gpuservice.instance.gpuCount.zero': 'CPU のみを使用し、環境準備用です。',
'gpuservice.instance.mode.whole': 'GPU 全体',
'gpuservice.instance.mode.sliced': '比率で',
'gpuservice.instance.slice.memoryPercentage': 'VRAM の割合(%',
'gpuservice.instance.slice.fullCores': '100% コンピュート',
'gpuservice.instance.slice.percentage.required':
'パーセンテージを選択または入力してください',
'gpuservice.instance.slice.percentage.max':
'比率は 1% から {count}% の間で指定してください',
'gpuservice.instance.stock': '在庫',
'gpuservice.instance.sliced': '分割',
'gpuservice.instance.sliceable': '分割可能',
'gpuservice.instance.memory': 'VRAM',
'gpuservice.instance.ram': 'RAM',
'gpuservice.instance.os': 'OS',
+9
View File
@@ -117,8 +117,17 @@ export default {
'gpuservice.instance.gpuCount.noAvailable':
'Нет доступных ресурсов GPU, выберите другой тип экземпляра.',
'gpuservice.instance.gpuCount.zero': 'Только CPU, для подготовки окружения.',
'gpuservice.instance.mode.whole': 'Весь GPU',
'gpuservice.instance.mode.sliced': 'По доле',
'gpuservice.instance.slice.memoryPercentage': 'Доля VRAM (%)',
'gpuservice.instance.slice.fullCores': '100% вычислений',
'gpuservice.instance.slice.percentage.required':
'Выберите или введите процент',
'gpuservice.instance.slice.percentage.max':
'Доля должна быть от 1% до {count}%',
'gpuservice.instance.stock': 'Остаток',
'gpuservice.instance.sliced': 'Разделено',
'gpuservice.instance.sliceable': 'Делимый',
'gpuservice.instance.memory': 'VRAM',
'gpuservice.instance.ram': 'RAM',
'gpuservice.instance.os': 'ОС',
+9
View File
@@ -113,8 +113,17 @@ export default {
'gpuservice.instance.gpuCount.noAvailable':
'Kullanılabilir GPU kaynağı yok, lütfen başka bir örnek türü seçin.',
'gpuservice.instance.gpuCount.zero': 'Yalnızca CPU, ortam hazırlığı için.',
'gpuservice.instance.mode.whole': 'Tam GPU',
'gpuservice.instance.mode.sliced': 'Orana Göre',
'gpuservice.instance.slice.memoryPercentage': 'VRAM Yüzdesi (%)',
'gpuservice.instance.slice.fullCores': '%100 İşlem Gücü',
'gpuservice.instance.slice.percentage.required':
'Lütfen bir yüzde seçin veya girin',
'gpuservice.instance.slice.percentage.max':
'Oran %1 ile %{count} arasında olmalıdır',
'gpuservice.instance.stock': 'Stok',
'gpuservice.instance.sliced': 'Bölünmüş',
'gpuservice.instance.sliceable': 'Bölünebilir',
'gpuservice.instance.memory': 'VRAM',
'gpuservice.instance.ram': 'RAM',
'gpuservice.instance.os': 'OS',
+7
View File
@@ -108,8 +108,15 @@ export default {
'gpuservice.instance.gpuCount.noAvailable':
'没有可用的 GPU 资源,请选择其他实例类型。',
'gpuservice.instance.gpuCount.zero': '仅使用 CPU,用于环境准备。',
'gpuservice.instance.mode.whole': '整卡',
'gpuservice.instance.mode.sliced': '按比例',
'gpuservice.instance.slice.memoryPercentage': '显存占比(%',
'gpuservice.instance.slice.fullCores': '100% 算力',
'gpuservice.instance.slice.percentage.required': '请选择或输入百分比',
'gpuservice.instance.slice.percentage.max': '比例需在 1% 到 {count}% 之间',
'gpuservice.instance.stock': '库存',
'gpuservice.instance.sliced': '切分',
'gpuservice.instance.sliceable': '可切分',
'gpuservice.instance.memory': '显存',
'gpuservice.instance.ram': '内存',
'gpuservice.instance.os': '系统',
@@ -23,6 +23,11 @@ interface NumberSelectionProps {
labelExtra?: React.ReactNode;
maxCount?: number;
tips?: string;
// Explicit preset tick values (e.g. [10,20,...,100] for percentage slicing).
// Overrides the default 1..maxCount sequence.
presetValues?: number[];
// Force the free-input box to show regardless of max/maxCount.
alwaysShowInput?: boolean;
onChange?: (value: number) => void;
}
@@ -39,17 +44,18 @@ const NumberSelection: React.FC<NumberSelectionProps> = ({
className,
maxCount = 8,
tips,
presetValues,
alwaysShowInput,
style,
onChange
}) => {
const intl = useIntl();
const showCustomInput = max > maxCount;
const presetItems = Array.from(
{ length: Math.max(0, maxCount) },
(_, i) => i + 1
);
if (min <= 0) {
const showCustomInput = alwaysShowInput || max > maxCount;
const presetItems =
presetValues ??
Array.from({ length: Math.max(0, maxCount) }, (_, i) => i + 1);
if (!presetValues && min <= 0) {
presetItems.unshift(0);
}
const items = presetItems;
@@ -39,9 +39,20 @@
display: flex;
align-items: center;
padding: 0 12px;
padding-right: 2px;
color: var(--ant-color-text-tertiary);
white-space: nowrap;
padding-block: 6px;
width: 100%;
// Spread the label and its labelExtra (e.g. the whole/sliced Segmented)
// to opposite ends of the row.
:global(.label-text) {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
}
}
.contentWrapper {
width: 100%;
@@ -59,6 +59,9 @@ interface InstanceTypeItemProps {
interface MetadataSectionProps {
spec: InstanceTypeItemModel['spec'];
// status.onceMaxRequest.acceleratorSliced (max sliceable percentage). Shown
// next to Max for sliceable types.
slicedMaxPercentage?: number;
}
const MetaItem: React.FC<{
@@ -125,7 +128,8 @@ function getInstanceDerived(item: InstanceTypeItemModel) {
}
export const InstanceMetadataSection: React.FC<MetadataSectionProps> = ({
spec
spec,
slicedMaxPercentage
}) => {
const intl = useIntl();
@@ -133,6 +137,14 @@ export const InstanceMetadataSection: React.FC<MetadataSectionProps> = ({
spec
} as InstanceTypeItemModel);
// Sliceable types show "Max {n} · Sliceable {m}%" instead of just the count.
const showSliceable = !!spec.sliceable && (slicedMaxPercentage ?? 0) > 0;
const maxValue = showSliceable
? `${spec.maxComputeUnitCount || 0} · ${intl.formatMessage({
id: 'gpuservice.instance.sliceable'
})} ${slicedMaxPercentage}%`
: `${spec.maxComputeUnitCount || 0}`;
return (
<Meta $columns={isGPU ? 11 : 7}>
{isGPU && (
@@ -159,7 +171,7 @@ export const InstanceMetadataSection: React.FC<MetadataSectionProps> = ({
},
{ count: '' }
)}
value={`${spec.maxComputeUnitCount || 0}`}
value={maxValue}
/>
{/* row 2: OS | Arch | CPU */}
<MetaItem
@@ -278,7 +290,12 @@ const InstanceTypeItem: React.FC<InstanceTypeItemProps> = ({ item }) => {
/>
</Flex>
</Title>
<InstanceMetadataSection spec={specData}></InstanceMetadataSection>
<InstanceMetadataSection
spec={specData}
slicedMaxPercentage={
Number(item.status?.onceMaxRequest?.acceleratorSliced) || 0
}
></InstanceMetadataSection>
</Flex>
);
};
@@ -262,15 +262,15 @@ export const getAcceleratorMax = (
// Picks the candidate (cluster + type name) that should fulfill a requested
// accelerator count: the first candidate of the smallest tier whose
// onceMaxRequest.accelerator is >= the requested count and whose cpu/ram/localStorage
// remaining are all > 0.
// onceMaxRequest.accelerator is >= the requested count. Accelerated types are
// not gated on CPU remaining (only CPU-only types are); in sliced mode the
// candidate's acceleratorSliced remaining must also be > 0.
export const pickCandidateForAccelerator = <
C extends {
cluster: string;
name: string;
cpu?: { remaining?: string | null } | null;
ram?: { remaining?: string | null } | null;
localStorage?: { remaining?: string | null } | null;
acceleratorSliced?: { remaining?: string | null } | null;
}
>(
tiers:
@@ -280,14 +280,21 @@ export const pickCandidateForAccelerator = <
}[]
| undefined
| null,
{ count, acceleratable }: { count: number; acceleratable?: boolean }
{
count,
acceleratable,
sliced
}: { count: number; acceleratable?: boolean; sliced?: boolean }
): C | null => {
if (!tiers?.length) return null;
const hasResources = (c: C) =>
parseQuantity(c.cpu?.remaining) > 0 &&
parseQuantity(c.ram?.remaining) > 0 &&
parseQuantity(c.localStorage?.remaining) > 0;
const hasResources = (c: C) => {
// Accelerated types are not gated on CPU remaining; CPU-only types are.
if (!acceleratable && parseQuantity(c.cpu?.remaining) <= 0) return false;
if (sliced && parseQuantity(c.acceleratorSliced?.remaining) <= 0)
return false;
return true;
};
const sorted = [...tiers].sort(
(a, b) =>
@@ -45,6 +45,11 @@ export interface FormData {
ram: string | null | number;
localStorage: string | null | number;
accelerator: number | string | null;
// Sliced (percentage) mode only. Memory (VRAM) percentage bound to the
// 10-100 selector + free input; cores (compute) percentage bound to the
// "100% compute" checkbox (100 when checked, mirrors memory otherwise).
acceleratorSlicedMemoryPercentage?: number;
acceleratorSlicedCoresPercentage?: number;
};
volume: {
ephemeral?: {
@@ -128,8 +133,10 @@ export interface InstanceTypeCandidate {
name: string;
accelerator: InstanceTypeResource;
cpu: InstanceTypeResource;
ram: InstanceTypeResource;
localStorage: InstanceTypeResource;
// Shared-mode available resource (not shown in the GPU Instance form).
acceleratorShared: InstanceTypeResource;
// Sliced-mode available resource.
acceleratorSliced: InstanceTypeResource;
}
export interface InstanceTypeTierOnceMaxRequestResource {
@@ -149,6 +156,8 @@ export interface InstanceTypeOnceMaxRequestResource {
cpu: QuanityCPU;
ram: QuanityMemory;
localStorage: QuanityLocalStorage;
acceleratorShared: `${number}` | null;
acceleratorSliced: `${number}` | null;
}
export interface CPUCache {
@@ -181,6 +190,8 @@ export interface InstanceTypeSpec {
family?: string | null;
computeCapability?: string | null;
sliced?: string | null;
sliceable?: boolean;
localStorage?: QuanityLocalStorage;
maxComputeUnitCount?: number;
unitResources?: {
cpu: QuanityCPU;
+151 -14
View File
@@ -273,33 +273,94 @@ const GPUServiceInstanceForm: React.FC<InstanceFormProps> = forwardRef(
const buildResourcesDataForSubmit = (values: FormData) => {
const unitResourcesParsed = getUnitResources();
const accelerator = _.toNumber(values.spec?.resources?.accelerator) || 0;
const cpuCount = _.toNumber(values.spec?.resources?.cpu) || 0;
const resources = values.spec?.resources ?? ({} as any);
const accelerator = _.toNumber(resources.accelerator) || 0;
const cpuCount = _.toNumber(resources.cpu) || 0;
const cpuNum = unitResourcesParsed?.cpu?.num;
const ramNum = unitResourcesParsed?.ram?.num;
const fallbackCpu = values.spec?.resources?.cpu;
const fallbackCpu = resources.cpu;
const factor = isGPUType ? accelerator : cpuCount;
// Sliced mode: scale a single card's unit resources by the chosen
// percentage (floored) — both CPU and RAM. Whole/CPU mode: multiply the
// unit by the count.
const percentage = _.toNumber(
resources.acceleratorSlicedMemoryPercentage
);
const sliced = isGPUType && percentage > 0;
const wholeFactor = isGPUType ? accelerator : cpuCount;
const scale = (num: number, unit: string) =>
sliced
? `${Math.max(1, _.floor((num * percentage) / 100))}${unit}`
: `${wholeFactor * num}${unit}`;
return {
cpu: cpuNum
? `${factor * cpuNum}${unitResourcesParsed?.cpu?.unit || ''}`
? scale(cpuNum, unitResourcesParsed?.cpu?.unit || '')
: // Don't stringify an unset value — `${undefined}` becomes the
// literal "undefined", which fails k8s quantity validation.
fallbackCpu
? `${fallbackCpu}`
: undefined,
ram: ramNum
? `${factor * ramNum}${unitResourcesParsed?.ram?.unit || ''}`
: values.spec?.resources?.ram
? scale(ramNum, unitResourcesParsed?.ram?.unit || '')
: resources.ram
};
};
// Sliced display: set the (disabled) CPU / RAM inputs to a single card's
// unit resources scaled by the chosen percentage, floored. Reads the
// percentage straight from the form so it can be re-run after any slider
// change without threading values through.
const applySlicedResourceScaling = () => {
const unitResourcesParsed = getUnitResources();
const cpuCores = unitResourcesParsed?.cpu?.cores;
const ramValue = unitResourcesParsed?.ram?.value;
const percentage = _.toNumber(
form.getFieldValue([
'spec',
'resources',
'acceleratorSlicedMemoryPercentage'
])
);
form.setFieldsValue({
spec: {
resources: {
cpu:
cpuCores != null && percentage > 0
? Math.max(1, _.floor((cpuCores * percentage) / 100))
: null,
ram:
ramValue != null && percentage > 0
? Math.max(1, _.floor((ramValue * percentage) / 100))
: null
}
}
} as any);
};
// Single entry point for the sliced memory ratio: write the ratio (compute
// stays pinned at 100%) and rescale CPU / RAM off it. Reused by the slider
// onChange and by the sliced-mode defaults so both share one path.
const applySliceMemoryPercentage = (value: number) => {
form.setFieldsValue({
spec: {
resources: {
acceleratorSlicedMemoryPercentage: value,
acceleratorSlicedCoresPercentage: 100
}
}
} as any);
applySlicedResourceScaling();
};
const resolveAndApply = (
instanceType: InstanceTypeItem | undefined,
count: number
count: number,
sliced?: boolean
) => {
if (!instanceType) {
setSelectedInstanceType(undefined);
@@ -328,7 +389,8 @@ const GPUServiceInstanceForm: React.FC<InstanceFormProps> = forwardRef(
instanceType.status?.tiers,
{
count,
acceleratable: instanceType.spec?.acceleratable
acceleratable: instanceType.spec?.acceleratable,
sliced
}
);
@@ -336,9 +398,12 @@ const GPUServiceInstanceForm: React.FC<InstanceFormProps> = forwardRef(
setOnceMaxRequest({
cpu: ceilMilliToCore(candidate?.cpu?.onceMaxRequest)?.cores,
memory: parseQuantityToGi(candidate?.ram?.onceMaxRequest)?.value,
localStorage: parseQuantityToGi(candidate?.localStorage?.onceMaxRequest)
?.value
// candidate no longer carries ram/localStorage: memory max comes from
// the type-level onceMaxRequest.ram (already parsed to a Gi number by
// the query hook), disk max from spec.localStorage (UI-only cap).
memory: _.toNumber(instanceType.status?.onceMaxRequest?.ram) || null,
localStorage:
parseQuantityToGi(instanceType.spec?.localStorage)?.value ?? null
});
form.setFieldsValue({
@@ -354,8 +419,44 @@ const GPUServiceInstanceForm: React.FC<InstanceFormProps> = forwardRef(
});
};
// Whole-card (exclusive) vs sliced (percentage) mode. Only meaningful for
// sliceable accelerator types; derived (no persisted field) — on edit/
// recreate it is inferred from acceleratorSlicedMemoryPercentage > 0.
const [sliceMode, setSliceMode] = useState<'whole' | 'sliced'>('whole');
const handleAcceleratorChange = (count: number) => {
resolveAndApply(selectedInstanceType, count);
resolveAndApply(selectedInstanceType, count, false);
};
// Seed the sliced-mode default ratio for an instance type: 10% but never
// above the type's max sliceable ratio (status.onceMaxRequest
// .acceleratorSliced). Shares applySliceMemoryPercentage with the slider.
const applySlicedDefaults = (instanceType?: InstanceTypeItem) => {
const slicedMax =
_.toNumber(instanceType?.status?.onceMaxRequest?.acceleratorSliced) ||
0;
applySliceMemoryPercentage(slicedMax ? Math.min(10, slicedMax) : 10);
};
// Toggle between whole-card and sliced mode. Sliced fixes the accelerator
// count to 1 (a single card is partitioned by percentage) and clears the
// slice-percentage fields when leaving sliced mode.
const handleSliceModeChange = (mode: 'whole' | 'sliced') => {
setSliceMode(mode);
if (mode === 'sliced') {
resolveAndApply(selectedInstanceType, 1, true);
applySlicedDefaults(selectedInstanceType);
} else {
form.setFieldsValue({
spec: {
resources: {
acceleratorSlicedMemoryPercentage: undefined,
acceleratorSlicedCoresPercentage: undefined
}
}
} as any);
resolveAndApply(selectedInstanceType, 1, false);
}
};
const onTargetChange = (key: string) => {
@@ -407,6 +508,14 @@ const GPUServiceInstanceForm: React.FC<InstanceFormProps> = forwardRef(
? _.toNumber(currentData?.spec?.resources?.accelerator)
: _.toNumber(currentData?.spec?.resources?.cpu) || 0;
// Infer the mode from the persisted slice percentage (recreate keeps
// the section editable; edit/view render a readonly card).
const persistedSliced =
_.toNumber(
currentData?.spec?.resources?.acceleratorSlicedMemoryPercentage
) > 0;
setSliceMode(persistedSliced ? 'sliced' : 'whole');
form.setFieldsValue({
...currentData,
spec: {
@@ -424,6 +533,12 @@ const GPUServiceInstanceForm: React.FC<InstanceFormProps> = forwardRef(
enable_ssh: !!currentData?.spec?.sshPublicKeys?.length,
storageMode: detectMode(currentData?.spec?.volume)
});
// buildResourcesData above filled CPU / RAM for the whole card; rescale
// them off the persisted percentages when recreating a sliced instance.
if (persistedSliced) {
applySlicedResourceScaling();
}
}
}, [action, currentData, form, open, realAction, instanceTypeList]);
@@ -492,11 +607,30 @@ const GPUServiceInstanceForm: React.FC<InstanceFormProps> = forwardRef(
getFieldsValue: () => form.getFieldsValue(),
applyInstanceType: (instanceType?: InstanceTypeItem) => {
if (!instanceType) {
setSliceMode('whole');
resolveAndApply(undefined, 0);
return;
}
// set default to 1, for all instance types: GPU or non-GPU
// A sliceable type with no whole-card capacity (Max < 1) defaults to
// sliced mode — whole mode would have nothing selectable.
const wholeMax = instanceType.spec?.maxComputeUnitCount ?? 0;
const slicedMax =
_.toNumber(instanceType.status?.onceMaxRequest?.acceleratorSliced) ||
0;
const defaultSliced =
!!instanceType.spec?.sliceable && wholeMax < 1 && slicedMax > 0;
if (defaultSliced) {
setSliceMode('sliced');
resolveAndApply(instanceType, 1, true);
applySlicedDefaults(instanceType);
return;
}
// Otherwise default to whole-card mode (a new type may not be
// sliceable); set count to 1 for all instance types: GPU or non-GPU.
setSliceMode('whole');
resolveAndApply(instanceType, 1);
}
}));
@@ -607,6 +741,9 @@ const GPUServiceInstanceForm: React.FC<InstanceFormProps> = forwardRef(
currentData={currentData as any}
onceMaxRequest={onceMaxRequest}
noAvailableTypes={noAvailableInstanceTypes}
sliceMode={sliceMode}
onSliceModeChange={handleSliceModeChange}
onSliceMemoryPercentageChange={applySliceMemoryPercentage}
onGPUCountChange={handleAcceleratorChange}
/>
)
@@ -3,7 +3,7 @@ import { PageActionType } from '@/config/types';
import NumberSelection from '@/pages/_components/number-selection';
import { InputNumber } from '@gpustack/core-ui';
import { useIntl } from '@umijs/max';
import { Flex, Form } from 'antd';
import { Flex, Form, Segmented } from 'antd';
import _ from 'lodash';
import { useContext, useMemo } from 'react';
import styled from 'styled-components';
@@ -57,6 +57,9 @@ const InstanceTypePicker: React.FC<InstanceTypePickerProps> = ({
);
};
// Fixed 10-tick percentage scale (10..100) for the sliced (percentage) mode.
const SLICE_PERCENT_TICKS = [10, 20, 30, 40, 50, 60, 70, 80, 90, 100];
interface InstanceTypeFormItemProps {
action: PageActionType;
disabled?: boolean;
@@ -67,6 +70,12 @@ interface InstanceTypeFormItemProps {
// org owns no clusters. Surface a "no available" message instead of the
// "please select" placeholder + empty CPU / memory inputs.
noAvailableTypes?: boolean;
// Whole-card (exclusive) vs sliced (percentage) mode. Owned by the parent
// form (it drives candidate picking + the fixed accelerator=1 for sliced).
sliceMode?: 'whole' | 'sliced';
onSliceModeChange?: (mode: 'whole' | 'sliced') => void;
// Commit a new sliced memory ratio (writes the field + rescales CPU / RAM).
onSliceMemoryPercentageChange?: (value: number) => void;
onGPUCountChange?: (value: number) => void;
}
@@ -77,9 +86,13 @@ const InstanceTypeFormItem: React.FC<InstanceTypeFormItemProps> = ({
selectedInstanceType,
onceMaxRequest,
noAvailableTypes,
sliceMode = 'whole',
onSliceModeChange,
onSliceMemoryPercentageChange,
onGPUCountChange
}) => {
const intl = useIntl();
const form = Form.useFormInstance();
const { isGPUType } = useContext(FormContext);
const maxComputeUnitCount = useMemo(() => {
@@ -104,6 +117,60 @@ const InstanceTypeFormItem: React.FC<InstanceTypeFormItemProps> = ({
onGPUCountChange?.(value);
};
// Sliced mode is only offered for sliceable accelerator types, and only when
// the section is editable (create / recreate; edit renders a readonly card).
const showModeSwitch =
action !== PageAction.EDIT &&
isGPUType &&
!!selectedInstanceType?.spec?.sliceable;
const handleModeChange = (value: string) => {
onSliceModeChange?.(value as 'whole' | 'sliced');
};
// Memory (VRAM) percentage changed via the slider/input — forward the new
// value so the parent writes the field and rescales CPU / RAM.
const handleMemoryPercentageChange = (value: number) => {
onSliceMemoryPercentageChange?.(value);
};
const modeSegmented = showModeSwitch ? (
<Segmented
size="small"
shape="round"
style={{ fontSize: 12 }}
value={sliceMode}
disabled={disabled}
onChange={handleModeChange}
options={[
{
label: intl.formatMessage({ id: 'gpuservice.instance.mode.whole' }),
value: 'whole'
},
{
label: intl.formatMessage({ id: 'gpuservice.instance.mode.sliced' }),
value: 'sliced'
}
]}
/>
) : null;
const isSliced = showModeSwitch && sliceMode === 'sliced';
// Max selectable ratio in sliced mode: status.onceMaxRequest.acceleratorSliced
// (a percentage). Ticks above it stay visible but disabled.
const slicedMaxPercentage =
_.toNumber(
selectedInstanceType?.status?.onceMaxRequest?.acceleratorSliced
) || 0;
// When the max ratio is below 10%, switch the ticks to a finer 1..10 scale
// so small slices are still selectable; otherwise use the 10..100 scale.
const sliceTicks =
slicedMaxPercentage < 10
? [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
: SLICE_PERCENT_TICKS;
const renderMaxLabel = (
label: React.ReactNode,
max?: number | null
@@ -206,7 +273,7 @@ const InstanceTypeFormItem: React.FC<InstanceTypeFormItemProps> = ({
: ['spec', 'resources', 'cpu']
}
preserve
hidden={action === PageAction.EDIT}
hidden={action === PageAction.EDIT || isSliced}
normalize={(value) => (value != null ? _.toString(value) : undefined)}
getValueProps={(value) => ({
value: value != null ? _.toNumber(value) : undefined
@@ -247,6 +314,7 @@ const InstanceTypeFormItem: React.FC<InstanceTypeFormItemProps> = ({
max={maxComputeUnitCount}
step={1}
required
labelExtra={sliceMode === 'whole' ? modeSegmented : undefined}
disabled={disabled || action === PageAction.EDIT}
label={`${intl.formatMessage({ id: 'common.max.count' }, { label: numberSelectionLabel.label })} (${intl.formatMessage(
{
@@ -257,6 +325,89 @@ const InstanceTypeFormItem: React.FC<InstanceTypeFormItemProps> = ({
/>
</Form.Item>
)}
{!noAvailableTypes && isSliced && (
<FieldBlock>
<Form.Item<FormData>
name={['spec', 'resources', 'acceleratorSlicedMemoryPercentage']}
getValueProps={(value) => ({
value: value != null ? _.toNumber(value) : undefined
})}
rules={[
{
required: true,
validator: (_, value) => {
const num = Number(value);
if (value == null || value === '' || Number.isNaN(num)) {
return Promise.reject(
new Error(
intl.formatMessage({
id: 'gpuservice.instance.slice.percentage.required'
})
)
);
}
if (num > slicedMaxPercentage || num <= 0) {
return Promise.reject(
new Error(
intl.formatMessage(
{
id: 'gpuservice.instance.slice.percentage.max'
},
{ count: slicedMaxPercentage }
)
)
);
}
return Promise.resolve();
}
}
]}
>
<NumberSelection
min={1}
max={slicedMaxPercentage}
step={1}
maxCount={sliceTicks.length}
presetValues={sliceTicks}
alwaysShowInput
required
disabled={disabled}
onChange={handleMemoryPercentageChange}
labelExtra={modeSegmented}
label={intl.formatMessage({
id: 'gpuservice.instance.slice.memoryPercentage'
})}
/>
</Form.Item>
{/* Compute (cores) percentage is fixed at 100. Kept in the form via a
hidden item so it rides along on submit. */}
<Form.Item<FormData>
name={['spec', 'resources', 'acceleratorSlicedCoresPercentage']}
hidden
>
<InputNumber />
</Form.Item>
</FieldBlock>
)}
{/* Edit renders a readonly card (no sliced UI), so register the slice
percentages as hidden fields — otherwise their persisted values are
dropped from the submit payload. */}
{action === PageAction.EDIT && (
<>
<Form.Item<FormData>
name={['spec', 'resources', 'acceleratorSlicedMemoryPercentage']}
hidden
>
<InputNumber />
</Form.Item>
<Form.Item<FormData>
name={['spec', 'resources', 'acceleratorSlicedCoresPercentage']}
hidden
>
<InputNumber />
</Form.Item>
</>
)}
{!noAvailableTypes && (
<Flex gap={12}>
<div style={{ flex: 1 }}>
@@ -281,11 +432,7 @@ const InstanceTypeFormItem: React.FC<InstanceTypeFormItemProps> = ({
key="cpu_input"
preserve
>
<InputNumber
label={'CPU'}
max={onceMaxRequest?.cpu ?? undefined}
disabled={true}
/>
<InputNumber label={'CPU'} disabled={true} />
</Form.Item>
</div>
)}
@@ -36,6 +36,20 @@ export default function useQueryInstanceTypes() {
}
const max = getAcceleratorMax(item.status?.tiers);
// Sliceable types stay selectable as long as either whole-card or sliced
// capacity remains; unavailable only when both status.onceMaxRequest
// .accelerator and .acceleratorSliced are 0.
if (item.spec?.sliceable) {
const wholeMax = Number(item.status?.onceMaxRequest?.accelerator) || 0;
const slicedMax =
Number(item.status?.onceMaxRequest?.acceleratorSliced) || 0;
return {
maxComputeUnitCount: max || 0,
available: wholeMax > 0 || slicedMax > 0
};
}
return {
maxComputeUnitCount: max || 0,
available: (max || 0) > 0
@@ -11,7 +11,7 @@
* with ``buildInstanceTypeRecordFromMiB`` and feed it here.
*/
import _ from 'lodash';
import { parseJsonSafe } from '../../utils';
import { parseJsonSafe, parseQuantityToGi } from '../../utils';
import InstanceTypeCell from '../components/instance-type-cell';
import { formatMemoryDisplay } from '../config';
import { InstanceTypeSpec, ListItem } from '../config/types';
@@ -49,6 +49,10 @@ const buildResourcesData = (
return {};
};
// Memory (VRAM) percentage for a sliced instance; 0 when not sliced.
const getSliceMemoryPercentage = (record: ListItem) =>
_.toNumber(record.spec?.resources?.acceleratorSlicedMemoryPercentage) || 0;
const formatResources = (
instanceTypeSpec: { spec: InstanceTypeSpec },
record: ListItem
@@ -71,6 +75,34 @@ const formatResources = (
};
}
const sliceMemoryPercentage = getSliceMemoryPercentage(record);
// Sliced: CPU / RAM carry the already-scaled values on spec.resources, and
// VRAM is the per-card memory scaled by the memory percentage (floored,
// min 1) — not the whole card's size.
if (sliceMemoryPercentage > 0) {
const vramGi = parseQuantityToGi(
(instanceTypeSpec.spec as any)?.memory
)?.value;
const vram =
vramGi != null
? `${Math.max(1, _.floor((vramGi * sliceMemoryPercentage) / 100))} GB`
: undefined;
return {
cpu: record.spec?.resources?.cpu
? `${record.spec?.resources?.cpu} vCPU`
: '-',
ram: record.spec?.resources?.ram
? toGB(record.spec?.resources?.ram)
: '-',
vram,
localStorage: record.spec?.resources?.localStorage
? toGB(record.spec?.resources?.localStorage)
: undefined
};
}
// VRAM = per-card GPU memory (a single card's size; not aggregated across
// cards — the model's marquee spec).
const vram = formatMemoryDisplay((instanceTypeSpec.spec as any)?.memory);
@@ -108,10 +140,14 @@ export const renderInstanceType = (
parseJsonSafe<any>(record?.description || '{}', {}).spec || {};
const resources = formatResources({ spec: description }, record);
const accelerator = record.spec?.resources?.accelerator;
const sliceMemoryPercentage = getSliceMemoryPercentage(record);
const isSliced = description.acceleratable && sliceMemoryPercentage > 0;
const title =
options.title ??
(description.acceleratable
? `${description.product} x ${accelerator}`
? isSliced
? `${description.product} (${sliceMemoryPercentage}%)`
: `${description.product} x ${accelerator}`
: 'CPU Only');
const volume = (record.spec as any)?.volume;
@@ -131,10 +167,16 @@ export const renderInstanceType = (
icon: 'icon-gpu',
name: 'GPU',
rows: [
[
intl.formatMessage({ id: 'gpuservice.table.count' }),
accelerator ? `${accelerator}` : undefined
],
// Sliced instances show the ratio instead of a card count (always 1).
isSliced
? [
intl.formatMessage({ id: 'gpuservice.instance.sliced' }),
`${sliceMemoryPercentage}%`
]
: [
intl.formatMessage({ id: 'gpuservice.table.count' }),
accelerator ? `${accelerator}` : undefined
],
[
intl.formatMessage({ id: 'gpuservice.instance.section.type' }),
description.product