feat(gpu-service): add sliced compute ratio, confirm activate/deactivate, watch instance types

- add configurable compute (cores) percentage in sliced mode (cores >= memory)
- confirm instance-type activate/deactivate via DeleteModal
- live-sync instance types list via chunked watch
This commit is contained in:
jialin
2026-07-22 15:52:17 +08:00
parent 96d194a4f4
commit 74f1399e3c
14 changed files with 299 additions and 87 deletions
+6
View File
@@ -222,6 +222,12 @@ export default {
'common.title.delete.confirm': 'Confirm deletion',
'common.title.stop.confirm': 'Confirm stop',
'common.title.start.confirm': 'Confirm start',
'common.title.activate.confirm': 'Confirm activate',
'common.title.deactivate.confirm': 'Confirm deactivate',
'common.activate.single.confirm':
'Are you sure you want to activate? \n <span style="font-size: 13px;font-weight: 700">{name}</span>',
'common.deactivate.single.confirm':
'Are you sure you want to deactivate? \n <span style="font-size: 13px;font-weight: 700">{name}</span>',
'common.title.recreate.confirm': 'Confirm recreate',
'common.button.addLabel': 'Add Label',
'common.button.addSelector': 'Add Selector',
+3
View File
@@ -122,6 +122,9 @@ export default {
'gpuservice.instance.mode.whole': 'Full GPU',
'gpuservice.instance.mode.sliced': 'By Ratio',
'gpuservice.instance.slice.memoryPercentage': 'VRAM Percentage (%)',
'gpuservice.instance.slice.coresPercentage': 'Compute Percentage (%)',
'gpuservice.instance.slice.cores.min':
'The compute ratio must be no less than the VRAM ratio ({count}%)',
'gpuservice.instance.slice.fullCores': '100% Compute',
'gpuservice.instance.slice.percentage.required':
'Please select or enter a percentage',
+6
View File
@@ -221,6 +221,12 @@ export default {
'common.title.delete.confirm': '削除を確認',
'common.title.stop.confirm': '停止を確認',
'common.title.start.confirm': '開始を確認',
'common.title.activate.confirm': '有効化を確認',
'common.title.deactivate.confirm': '無効化を確認',
'common.activate.single.confirm':
'<span style="font-size: 13px;font-weight: 700">{name}</span> を有効化してもよろしいですか?',
'common.deactivate.single.confirm':
'<span style="font-size: 13px;font-weight: 700">{name}</span> を無効化してもよろしいですか?',
'common.title.recreate.confirm': '再作成を確認',
'common.button.addLabel': 'ラベルを追加',
'common.button.addSelector': 'セレクターを追加',
+3
View File
@@ -121,6 +121,9 @@ export default {
'gpuservice.instance.mode.whole': 'GPU 全体',
'gpuservice.instance.mode.sliced': '比率で',
'gpuservice.instance.slice.memoryPercentage': 'VRAM の割合(%',
'gpuservice.instance.slice.coresPercentage': '演算能力の割合(%',
'gpuservice.instance.slice.cores.min':
'演算能力の割合は VRAM の割合({count}%)以上である必要があります',
'gpuservice.instance.slice.fullCores': '100% コンピュート',
'gpuservice.instance.slice.percentage.required':
'パーセンテージを選択または入力してください',
+6
View File
@@ -219,6 +219,12 @@ export default {
'common.title.delete.confirm': 'Подтверждение удаления',
'common.title.stop.confirm': 'Подтверждение остановки',
'common.title.start.confirm': 'Подтверждение запуска',
'common.title.activate.confirm': 'Подтверждение активации',
'common.title.deactivate.confirm': 'Подтверждение деактивации',
'common.activate.single.confirm':
'Вы уверены, что хотите активировать <span style="font-size: 13px;font-weight: 700">{name}</span>?',
'common.deactivate.single.confirm':
'Вы уверены, что хотите деактивировать <span style="font-size: 13px;font-weight: 700">{name}</span>?',
'common.title.recreate.confirm': 'Подтверждение пересоздания',
'common.button.addLabel': 'Добавить метку',
'common.button.addSelector': 'Добавить селектор',
+3
View File
@@ -120,6 +120,9 @@ export default {
'gpuservice.instance.mode.whole': 'Весь GPU',
'gpuservice.instance.mode.sliced': 'По доле',
'gpuservice.instance.slice.memoryPercentage': 'Доля VRAM (%)',
'gpuservice.instance.slice.coresPercentage': 'Доля вычислений (%)',
'gpuservice.instance.slice.cores.min':
'Доля вычислений должна быть не меньше доли VRAM ({count}%)',
'gpuservice.instance.slice.fullCores': '100% вычислений',
'gpuservice.instance.slice.percentage.required':
'Выберите или введите процент',
+6
View File
@@ -224,6 +224,12 @@ export default {
'common.title.delete.confirm': 'Silme onayı',
'common.title.stop.confirm': 'Durdurma onayı',
'common.title.start.confirm': 'Başlatma onayı',
'common.title.activate.confirm': 'Etkinleştirme onayı',
'common.title.deactivate.confirm': 'Devre dışı bırakma onayı',
'common.activate.single.confirm':
'Etkinleştirmek istediğinizden emin misiniz? \n <span style="font-size: 13px;font-weight: 700">{name}</span>',
'common.deactivate.single.confirm':
'Devre dışı bırakmak istediğinizden emin misiniz? \n <span style="font-size: 13px;font-weight: 700">{name}</span>',
'common.title.recreate.confirm': 'Yeniden oluşturma onayı',
'common.button.addLabel': 'Etiket Ekle',
'common.button.addSelector': 'Seçici Ekle',
+3
View File
@@ -116,6 +116,9 @@ export default {
'gpuservice.instance.mode.whole': 'Tam GPU',
'gpuservice.instance.mode.sliced': 'Orana Göre',
'gpuservice.instance.slice.memoryPercentage': 'VRAM Yüzdesi (%)',
'gpuservice.instance.slice.coresPercentage': 'İşlem Gücü Yüzdesi (%)',
'gpuservice.instance.slice.cores.min':
'İşlem gücü oranı VRAM oranından ({count}%) küçük olamaz',
'gpuservice.instance.slice.fullCores': '%100 İşlem Gücü',
'gpuservice.instance.slice.percentage.required':
'Lütfen bir yüzde seçin veya girin',
+6
View File
@@ -212,6 +212,12 @@ export default {
'common.title.delete.confirm': '确认删除',
'common.title.stop.confirm': '确认停止',
'common.title.start.confirm': '确认启动',
'common.title.activate.confirm': '确认启用',
'common.title.deactivate.confirm': '确认停用',
'common.activate.single.confirm':
'确定启用 <span style="font-size: 13px;font-weight: 700">{name}</span>',
'common.deactivate.single.confirm':
'确定停用 <span style="font-size: 13px;font-weight: 700">{name}</span>',
'common.title.recreate.confirm': '确认重新创建',
'common.button.addLabel': '添加标签',
'common.button.addSelector': '添加选择器',
+2
View File
@@ -111,6 +111,8 @@ export default {
'gpuservice.instance.mode.whole': '整卡',
'gpuservice.instance.mode.sliced': '按比例',
'gpuservice.instance.slice.memoryPercentage': '显存占比(%',
'gpuservice.instance.slice.coresPercentage': '算力占比(%',
'gpuservice.instance.slice.cores.min': '算力占比需不小于显存占比 {count}%',
'gpuservice.instance.slice.fullCores': '100% 算力',
'gpuservice.instance.slice.percentage.required': '请选择或输入百分比',
'gpuservice.instance.slice.percentage.max': '比例需在 1% 到 {count}% 之间',
+28 -14
View File
@@ -39,7 +39,8 @@ const GPUServiceInstanceTypes: React.FC = () => {
const {
dataList,
loading: instanceTypesLoading,
fetchInstanceTypes
fetchInstanceTypes,
startWatch
} = useQueryInstanceTypes();
const { fetchData: createInstanceType } = useCreateInstanceType();
const {
@@ -66,16 +67,18 @@ const GPUServiceInstanceTypes: React.FC = () => {
if (firstK8s?.id != null) {
setClusterId(firstK8s.id);
await fetchInstanceTypes(firstK8s.id);
startWatch(firstK8s.id);
}
setLoaded(true);
};
init();
}, []);
const handleClusterChange = useMemoizedFn((value: number) => {
const handleClusterChange = useMemoizedFn(async (value: number) => {
setClusterId(value);
setKeyword('');
fetchInstanceTypes(value);
await fetchInstanceTypes(value);
startWatch(value);
});
const handleRefresh = useMemoizedFn(() => {
@@ -125,18 +128,29 @@ const GPUServiceInstanceTypes: React.FC = () => {
});
const handleToggleActive = useMemoizedFn(
async (record: ListItem, activate: boolean) => {
(record: ListItem, activate: boolean) => {
if (clusterId == null) return;
try {
const action = activate
? activateGPUInstanceType
: deactivateGPUInstanceType;
await action({ name: record.name, cluster_id: clusterId });
message.success(intl.formatMessage({ id: 'common.message.success' }));
fetchInstanceTypes(clusterId);
} catch (error) {
// handled by the request interceptor
}
const action = activate
? activateGPUInstanceType
: deactivateGPUInstanceType;
deleteModalRef.current?.show({
content: intl.formatMessage({ id: 'gpuservice.instanceType' }),
title: activate
? 'common.title.activate.confirm'
: 'common.title.deactivate.confirm',
okText: activate
? 'gpuservice.instanceType.activate'
: 'gpuservice.instanceType.deactivate',
operation: activate
? 'common.activate.single.confirm'
: 'common.deactivate.single.confirm',
name: record.spec?.displayName || record.name,
async onOk() {
await action({ name: record.name, cluster_id: clusterId });
message.success(intl.formatMessage({ id: 'common.message.success' }));
fetchInstanceTypes(clusterId);
}
});
}
);
@@ -1,15 +1,49 @@
import { createAxiosToken } from '@/hooks/use-chunk-request';
import { WatchEventType } from '@/config';
import useSetChunkRequest, {
createAxiosToken
} from '@/hooks/use-chunk-request';
import { useRequest } from 'ahooks';
import { CancelTokenSource } from 'axios';
import qs from 'query-string';
import { useEffect, useRef, useState } from 'react';
import { queryGPUInstanceTypes } from '../apis';
import { GPU_INSTANCE_TYPES_API, queryGPUInstanceTypes } from '../apis';
import { ListItem } from '../config/types';
// Merge a batch of watch events into the current name-keyed list. Instance
// types have no numeric id, so we upsert / remove by `name` rather than reuse
// the shared id-based chunked-list helper.
const mergeWatchEvents = (current: ListItem[], events: any[]) => {
let list = [...current];
events.forEach((event: any) => {
const collection: ListItem[] = event?.collection || [];
if (event?.type === WatchEventType.DELETE) {
const names = collection.map((item) => item.name);
list = list.filter((item) => !names.includes(item.name));
} else if (
event?.type === WatchEventType.CREATE ||
event?.type === WatchEventType.UPDATE
) {
collection.forEach((item) => {
const index = list.findIndex((it) => it.name === item.name);
if (index > -1) {
list[index] = item;
} else {
list = [item, ...list];
}
});
}
});
return list;
};
// Cluster-scoped instance types for the management list. Action-driven:
// call fetchInstanceTypes(clusterId) from the cluster picker / refresh, not
// via an effect dependency.
// via an effect dependency. startWatch(clusterId) opens a chunked watch that
// keeps the list in sync with live create/update/delete events.
export default function useQueryInstanceTypes() {
const tokenRef = useRef<CancelTokenSource | null>(null);
const chunkRequestRef = useRef<any>(null);
const { setChunkRequest } = useSetChunkRequest();
const [dataList, setDataList] = useState<ListItem[]>([]);
const {
@@ -43,10 +77,27 @@ export default function useQueryInstanceTypes() {
tokenRef.current?.cancel('CANCEL_PREVIOUS_REQUEST');
};
const stopWatch = () => {
chunkRequestRef.current?.current?.cancel?.();
};
const startWatch = (clusterId: number) => {
stopWatch();
chunkRequestRef.current = setChunkRequest({
url: `${GPU_INSTANCE_TYPES_API}?${qs.stringify({
cluster_id: clusterId
})}`,
handler: (events: any[]) => {
setDataList((pre) => mergeWatchEvents(pre, events));
}
});
};
useEffect(() => {
return () => {
cancel();
tokenRef.current?.cancel();
stopWatch();
};
}, []);
@@ -55,6 +106,8 @@ export default function useQueryInstanceTypes() {
loading,
fetchInstanceTypes,
cancelRequest,
startWatch,
stopWatch,
setDataList
};
}
@@ -350,21 +350,39 @@ const GPUServiceInstanceForm: React.FC<InstanceFormProps> = forwardRef(
} 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.
// Single entry point for the sliced memory ratio: write the ratio and
// rescale CPU / RAM off it. The compute (cores) ratio must stay >= memory,
// so bump it up when memory overtakes it. Reused by the slider onChange.
const applySliceMemoryPercentage = (value: number) => {
const currentCores = _.toNumber(
form.getFieldValue([
'spec',
'resources',
'acceleratorSlicedCoresPercentage'
])
);
const coresPercentage = currentCores >= value ? currentCores : value;
form.setFieldsValue({
spec: {
resources: {
acceleratorSlicedMemoryPercentage: value,
acceleratorSlicedCoresPercentage: 100
acceleratorSlicedCoresPercentage: coresPercentage
}
}
} as any);
applySlicedResourceScaling();
};
// Compute (cores) ratio — a GPU-slice-only parameter that rides along on
// submit. It does not scale CPU / RAM (those track the memory ratio), so
// just write the field.
const applySliceCoresPercentage = (value: number) => {
form.setFieldValue(
['spec', 'resources', 'acceleratorSlicedCoresPercentage'],
value
);
};
const resolveAndApply = (
instanceType: InstanceTypeItem | undefined,
count: number,
@@ -436,14 +454,25 @@ const GPUServiceInstanceForm: React.FC<InstanceFormProps> = forwardRef(
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.
// Seed the sliced-mode defaults for an instance type: memory ratio at 10%
// (never above the type's max sliceable ratio, status.onceMaxRequest
// .acceleratorSliced), and the cores ratio defaulting to the same value
// (cores >= memory). Set both together so a fresh selection doesn't carry a
// stale cores value from a previous type.
const applySlicedDefaults = (instanceType?: InstanceTypeItem) => {
const slicedMax =
_.toNumber(instanceType?.status?.onceMaxRequest?.acceleratorSliced) ||
0;
applySliceMemoryPercentage(slicedMax ? Math.min(10, slicedMax) : 10);
const memoryPercentage = slicedMax ? Math.min(10, slicedMax) : 10;
form.setFieldsValue({
spec: {
resources: {
acceleratorSlicedMemoryPercentage: memoryPercentage,
acceleratorSlicedCoresPercentage: memoryPercentage
}
}
} as any);
applySlicedResourceScaling();
};
// Toggle between whole-card and sliced mode. Sliced fixes the accelerator
@@ -757,6 +786,7 @@ const GPUServiceInstanceForm: React.FC<InstanceFormProps> = forwardRef(
sliceMode={sliceMode}
onSliceModeChange={handleSliceModeChange}
onSliceMemoryPercentageChange={applySliceMemoryPercentage}
onSliceCoresPercentageChange={applySliceCoresPercentage}
onGPUCountChange={handleAcceleratorChange}
/>
)
@@ -1,9 +1,9 @@
import { PageAction } from '@/config';
import { PageActionType } from '@/config/types';
import NumberSelection from '@/pages/_components/number-selection';
import { InputNumber } from '@gpustack/core-ui';
import { InputNumber, LabelInfo } from '@gpustack/core-ui';
import { useIntl } from '@umijs/max';
import { Flex, Form, Segmented } from 'antd';
import { Divider, Flex, Form, Segmented } from 'antd';
import _ from 'lodash';
import { useContext, useMemo } from 'react';
import styled from 'styled-components';
@@ -76,6 +76,8 @@ interface InstanceTypeFormItemProps {
onSliceModeChange?: (mode: 'whole' | 'sliced') => void;
// Commit a new sliced memory ratio (writes the field + rescales CPU / RAM).
onSliceMemoryPercentageChange?: (value: number) => void;
// Commit a new sliced compute (cores) ratio (writes the field only).
onSliceCoresPercentageChange?: (value: number) => void;
onGPUCountChange?: (value: number) => void;
}
@@ -89,6 +91,7 @@ const InstanceTypeFormItem: React.FC<InstanceTypeFormItemProps> = ({
sliceMode = 'whole',
onSliceModeChange,
onSliceMemoryPercentageChange,
onSliceCoresPercentageChange,
onGPUCountChange
}) => {
const intl = useIntl();
@@ -139,6 +142,21 @@ const InstanceTypeFormItem: React.FC<InstanceTypeFormItemProps> = ({
onSliceMemoryPercentageChange?.(value);
};
// Compute (cores) percentage changed — forward the new value.
const handleCoresPercentageChange = (value: number) => {
onSliceCoresPercentageChange?.(value);
};
// The cores ratio must be >= the memory ratio, so ticks below the current
// memory percentage are disabled (min). Cores range is a fixed 10..100.
const slicedMemoryPercentage =
_.toNumber(
Form.useWatch(
['spec', 'resources', 'acceleratorSlicedMemoryPercentage'],
form
)
) || 1;
// Max selectable ratio in sliced mode: status.onceMaxRequest.acceleratorSliced
// (a percentage). Ticks above it stay visible but disabled.
const slicedMaxPercentage =
@@ -272,6 +290,13 @@ const InstanceTypeFormItem: React.FC<InstanceTypeFormItemProps> = ({
)}
</Form.Item>
</FieldBlock>
{showModeSwitch && (
<div>
<LabelInfo label={intl.formatMessage({ id: 'models.form.mode' })} />
<div style={{ marginTop: 8 }}>{modeSegmented}</div>
<Divider />
</div>
)}
{!noAvailableTypes && (
<Form.Item<FormData>
key={isGPUType ? 'accelerator' : 'cpu'}
@@ -322,7 +347,6 @@ const InstanceTypeFormItem: React.FC<InstanceTypeFormItemProps> = ({
max={maxComputeUnitCount}
step={1}
required
labelExtra={sliceMode === 'whole' ? modeSegmented : undefined}
disabled={disabled || readonlyType}
label={`${intl.formatMessage({ id: 'common.max.count' }, { label: numberSelectionLabel.label })} (${intl.formatMessage(
{
@@ -335,66 +359,113 @@ const InstanceTypeFormItem: React.FC<InstanceTypeFormItemProps> = ({
)}
{!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'
<SelectedCard>
<Form.Item<FormData>
name={['spec', 'resources', 'acceleratorSlicedMemoryPercentage']}
getValueProps={(value) => ({
value: value != null ? _.toNumber(value) : undefined
})}
/>
</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>
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}
style={{ border: 'none' }}
onChange={handleMemoryPercentageChange}
label={intl.formatMessage({
id: 'gpuservice.instance.slice.memoryPercentage'
})}
/>
</Form.Item>
{/* Compute (cores) percentage. Fixed 10..100 ticks; ticks below the
chosen memory ratio are disabled (cores must be >= memory). */}
<Form.Item<FormData>
name={['spec', 'resources', 'acceleratorSlicedCoresPercentage']}
style={{ marginBottom: 0 }}
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 < slicedMemoryPercentage || num > 100) {
return Promise.reject(
new Error(
intl.formatMessage(
{ id: 'gpuservice.instance.slice.cores.min' },
{ count: slicedMemoryPercentage }
)
)
);
}
return Promise.resolve();
}
}
]}
>
<NumberSelection
min={slicedMemoryPercentage}
max={100}
step={10}
maxCount={SLICE_PERCENT_TICKS.length}
presetValues={SLICE_PERCENT_TICKS}
alwaysShowInput
required
disabled={disabled}
onChange={handleCoresPercentageChange}
style={{ border: 'none' }}
label={intl.formatMessage({
id: 'gpuservice.instance.slice.coresPercentage'
})}
/>
</Form.Item>
</SelectedCard>
</FieldBlock>
)}
{/* A not-yet-re-typed edit renders a readonly card (no sliced UI), so