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:
@@ -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).
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': 'ОС',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user