feat: add speculative decoding

This commit is contained in:
jialin
2025-11-03 10:14:30 +08:00
parent 608b2e31c7
commit 3613fb3f69
15 changed files with 356 additions and 165 deletions
+4
View File
@@ -169,6 +169,10 @@
gap: 8px; gap: 8px;
} }
.gap-4 {
gap: 4px;
}
.gap-20 { .gap-20 {
gap: 20px; gap: 20px;
} }
+7 -1
View File
@@ -218,5 +218,11 @@ export default {
'models.form.ramRatio.tips': 'models.form.ramRatio.tips':
'Ratio of system RAM to GPU VRAM used for KV cache. For example, 2.0 means the cache in RAM can be twice as large as the GPU VRAM.', 'Ratio of system RAM to GPU VRAM used for KV cache. For example, 2.0 means the cache in RAM can be twice as large as the GPU VRAM.',
'models.form.ramSize.tips': `Maximum size of the KV cache stored in system memory (GiB). If set, this value overrides "{content}".`, 'models.form.ramSize.tips': `Maximum size of the KV cache stored in system memory (GiB). If set, this value overrides "{content}".`,
'models.form.chunkSize.tips': 'Number of tokens per KV cache chunk.' 'models.form.chunkSize.tips': 'Number of tokens per KV cache chunk.',
'models.form.flavor.throughput.tips':
'optimized for high throughput under high request concurrency.',
'models.form.flavor.latency.tips':
'optimized for low latency under low request concurrency.',
'models.form.flavor.reference.tips':
'the most compatible option with full precision.'
}; };
+7 -1
View File
@@ -218,7 +218,13 @@ export default {
'models.form.ramRatio.tips': 'models.form.ramRatio.tips':
'Ratio of system RAM to GPU VRAM used for KV cache. For example, 2.0 means the cache in RAM can be twice as large as the GPU VRAM.', 'Ratio of system RAM to GPU VRAM used for KV cache. For example, 2.0 means the cache in RAM can be twice as large as the GPU VRAM.',
'models.form.ramSize.tips': `Maximum size of the KV cache stored in system memory (GiB). If set, this value overrides "{content}".`, 'models.form.ramSize.tips': `Maximum size of the KV cache stored in system memory (GiB). If set, this value overrides "{content}".`,
'models.form.chunkSize.tips': 'Number of tokens per KV cache chunk.' 'models.form.chunkSize.tips': 'Number of tokens per KV cache chunk.',
'models.form.flavor.throughput.tips':
'optimized for high throughput under high request concurrency.',
'models.form.flavor.latency.tips':
'optimized for low latency under low request concurrency.',
'models.form.flavor.reference.tips':
'the most compatible option with full precision.'
}; };
// ========== To-Do: Translate Keys (Remove After Translation) ========== // ========== To-Do: Translate Keys (Remove After Translation) ==========
+10
View File
@@ -8,6 +8,7 @@ import {
BackendItem, BackendItem,
CatalogItem, CatalogItem,
CatalogSpec, CatalogSpec,
DraftModelItem,
EvaluateResult, EvaluateResult,
EvaluateSpec, EvaluateSpec,
FormData, FormData,
@@ -27,6 +28,8 @@ export const BACKEND_LIST_API = '/inference-backends/list';
export const MY_MODELS_API = '/my-models'; export const MY_MODELS_API = '/my-models';
export const DRAFT_MODELS_API = '/draft-models';
const setProxyUrl = (url: string) => { const setProxyUrl = (url: string) => {
return `/proxy?url=${encodeURIComponent(url)}`; return `/proxy?url=${encodeURIComponent(url)}`;
}; };
@@ -443,3 +446,10 @@ export async function queryMyModelDetail(id: number) {
method: 'GET' method: 'GET'
}); });
} }
export async function queryDraftModelList(params?: Global.SearchParams) {
return request<{ items: DraftModelItem[] }>(DRAFT_MODELS_API, {
method: 'GET',
params
});
}
+1 -23
View File
@@ -10,7 +10,7 @@ import { SearchOutlined, SyncOutlined } from '@ant-design/icons';
import { PageContainer } from '@ant-design/pro-components'; import { PageContainer } from '@ant-design/pro-components';
import { useIntl, useNavigate } from '@umijs/max'; import { useIntl, useNavigate } from '@umijs/max';
import { useMemoizedFn } from 'ahooks'; import { useMemoizedFn } from 'ahooks';
import { Button, Input, Pagination, Space, message } from 'antd'; import { Button, Input, Space, message } from 'antd';
import { useAtom } from 'jotai'; import { useAtom } from 'jotai';
import _ from 'lodash'; import _ from 'lodash';
import React, { useCallback, useEffect, useState } from 'react'; import React, { useCallback, useEffect, useState } from 'react';
@@ -159,17 +159,6 @@ const Catalog: React.FC = () => {
[openDeployModal] [openDeployModal]
); );
const handleOnPageChange = useCallback(
(page: number, pageSize?: number) => {
setQueryParams({
...queryParams,
page,
perPage: pageSize || 10
});
},
[queryParams]
);
const handleSearch = (e: any) => { const handleSearch = (e: any) => {
fetchData({ fetchData({
...queryParams, ...queryParams,
@@ -312,17 +301,6 @@ const Catalog: React.FC = () => {
subTitle={intl.formatMessage({ id: 'noresult.catalog.subTitle' })} subTitle={intl.formatMessage({ id: 'noresult.catalog.subTitle' })}
></NoResult> ></NoResult>
</ScrollerContext.Provider> </ScrollerContext.Provider>
<PageWrapper>
<Pagination
hideOnSinglePage={queryParams.perPage === 100}
align="end"
defaultCurrent={1}
total={dataSource.total}
pageSize={queryParams.perPage}
showSizeChanger
onChange={handleOnPageChange}
/>
</PageWrapper>
<DelopyBuiltInModal <DelopyBuiltInModal
open={openDeployModal.show} open={openDeployModal.show}
action={PageAction.CREATE} action={PageAction.CREATE}
+40 -42
View File
@@ -1,16 +1,16 @@
import fallbackImg from '@/assets/images/img.png'; import fallbackImg from '@/assets/images/img.png';
import AutoTooltip from '@/components/auto-tooltip'; import AutoTooltip from '@/components/auto-tooltip';
import IconFont from '@/components/icon-font'; import IconFont from '@/components/icon-font';
import TagWrapper from '@/components/tags-wrapper';
import ThemeTag from '@/components/tags-wrapper/theme-tag'; import ThemeTag from '@/components/tags-wrapper/theme-tag';
import { useIntl } from '@umijs/max'; import { useIntl } from '@umijs/max';
import { Typography } from 'antd'; import { Typography } from 'antd';
import classNames from 'classnames'; import classNames from 'classnames';
import _ from 'lodash'; import _ from 'lodash';
import React, { useCallback } from 'react'; import React, { useCallback, useMemo } from 'react';
import { modelCategories } from '../config'; import { modelCategories } from '../config';
import { CatalogItem as CatalogItemType } from '../config/types'; import { CatalogItem as CatalogItemType } from '../config/types';
import '../style/catalog-item.less'; import '../style/catalog-item.less';
import { categoryConfig } from './model-tag';
const COLORS = ['blue', 'purple', 'orange']; const COLORS = ['blue', 'purple', 'orange'];
interface CatalogItemProps { interface CatalogItemProps {
@@ -47,11 +47,38 @@ const CatalogItem: React.FC<CatalogItemProps> = (props) => {
height: 22 height: 22
}} }}
> >
{sItem}B {sItem.label}
</ThemeTag> </ThemeTag>
); );
}, []); }, []);
const description = useMemo(() => {
return (
<Typography.Paragraph
className="desc"
ellipsis={{
rows: 2,
tooltip: (
<div
className="custome-scrollbar"
style={{
display: 'flex',
justifyContent: 'flex-start',
maxHeight: 300,
maxWidth: 300,
overflow: 'auto'
}}
>
{data.description}
</div>
)
}}
>
{data.description}
</Typography.Paragraph>
);
}, [data.description]);
return ( return (
<div <div
onClick={handleOnClick} onClick={handleOnClick}
@@ -66,32 +93,8 @@ const CatalogItem: React.FC<CatalogItemProps> = (props) => {
onError={handleOnError} onError={handleOnError}
/> />
</div> </div>
<AutoTooltip ghost style={{ flex: 1 }}> <AutoTooltip ghost>{data.name}</AutoTooltip>
{data.name}
</AutoTooltip>
</div> </div>
<Typography.Paragraph
className="desc"
ellipsis={{
rows: 2,
tooltip: (
<div
className="custome-scrollbar"
style={{
display: 'flex',
justifyContent: 'flex-start',
maxHeight: 300,
maxWidth: 300,
overflow: 'auto'
}}
>
{data.description}
</div>
)
}}
>
{data.description}
</Typography.Paragraph>
</div> </div>
<div className="item-footer"> <div className="item-footer">
<div className="update-time"> <div className="update-time">
@@ -121,9 +124,10 @@ const CatalogItem: React.FC<CatalogItemProps> = (props) => {
{data.categories.map((sItem, i) => { {data.categories.map((sItem, i) => {
return ( return (
<ThemeTag <ThemeTag
icon={categoryConfig[sItem]?.icon}
key={sItem} key={sItem}
className="tag-item" className="tag-item"
color="blue" color={categoryConfig[sItem]?.color || 'blue'}
opacity={0.7} opacity={0.7}
> >
{_.find(modelCategories, { value: sItem })?.label || sItem} {_.find(modelCategories, { value: sItem })?.label || sItem}
@@ -147,22 +151,16 @@ const CatalogItem: React.FC<CatalogItemProps> = (props) => {
</ThemeTag> </ThemeTag>
); );
})} })}
{data.sizes?.length > 0 && ( <span className="dot"></span>
<> <ThemeTag>
<span className="dot"></span> {data.activated_size
<div className="box"> ? `${data.size}B-A${data.activated_size}B`
<TagWrapper : `${data.size}B`}
gap={8} </ThemeTag>
dataList={data.sizes}
renderTag={renderTag}
></TagWrapper>
</div>
</>
)}
</div> </div>
</div> </div>
</div> </div>
); );
}; };
export default React.memo(CatalogItem); export default CatalogItem;
@@ -540,8 +540,8 @@ const AddModal: React.FC<AddModalProps> = (props) => {
> >
<CatalogFormContext.Provider <CatalogFormContext.Provider
value={{ value={{
sizeOptions: sizeOptions, sizeOptions: [],
quantizationOptions: quantizationOptions, quantizationOptions: [],
onSizeChange: handleOnSizeChange, onSizeChange: handleOnSizeChange,
onQuantizationChange: handleOnQuantizationChange onQuantizationChange: handleOnQuantizationChange
}} }}
+12
View File
@@ -42,6 +42,9 @@ export const FormContext = React.createContext<FormContextProps>(
{} as FormContextProps {} as FormContextProps
); );
/**
* Catalog form context
*/
export const CatalogFormContext = React.createContext<CatalogFormContextProps>( export const CatalogFormContext = React.createContext<CatalogFormContextProps>(
{} as CatalogFormContextProps {} as CatalogFormContextProps
); );
@@ -50,6 +53,10 @@ export const FormOuterContext = React.createContext<FormOuterContextProps>(
{} as FormOuterContextProps {} as FormOuterContextProps
); );
/**
* Hooks to use the form context
*/
export const useFormContext = () => { export const useFormContext = () => {
const context = React.useContext(FormContext); const context = React.useContext(FormContext);
if (!context) { if (!context) {
@@ -68,6 +75,11 @@ export const useCatalogFormContext = () => {
return context; return context;
}; };
/**
*
* Hooks to use the outer form context
*/
export const useFormOuterContext = () => { export const useFormOuterContext = () => {
const context = React.useContext(FormOuterContext); const context = React.useContext(FormOuterContext);
if (!context) { if (!context) {
+22 -1
View File
@@ -82,6 +82,14 @@ export interface FormData {
ram_ratio: number; ram_ratio: number;
ram_size: number; ram_size: number;
}; };
speculative_config: {
enabled: boolean;
algorithm: string;
draft_model_name: string;
num_draft_tokens: number;
ngram_min_match_length: number;
ngram_max_match_length: number;
};
} }
interface ComputedResourceClaim { interface ComputedResourceClaim {
@@ -168,7 +176,8 @@ export interface CatalogItem {
icon: string; icon: string;
categories: string[]; categories: string[];
capabilities: string[]; capabilities: string[];
sizes: number[]; size: number;
activated_size: number;
licenses: string[]; licenses: string[];
release_date: string; release_date: string;
} }
@@ -279,3 +288,15 @@ export interface BackendItem {
is_deprecated: boolean; is_deprecated: boolean;
}[]; }[];
} }
export interface DraftModelItem {
source: string;
huggingface_repo_id: string;
huggingface_filename: string;
ollama_library_model_name: string;
model_scope_model_id: string;
model_scope_file_path: string;
local_path: string;
name: string;
algorithm: string;
}
+28 -34
View File
@@ -81,41 +81,35 @@ const AdvanceConfig = () => {
[backendOptionsMap.vllm, backendOptionsMap.ascendMindie].includes( [backendOptionsMap.vllm, backendOptionsMap.ascendMindie].includes(
backend backend
) && ( ) && (
<div style={{ paddingBottom: 22 }}> <Form.Item<FormData>
<Form.Item<FormData> name="distributed_inference_across_workers"
name="distributed_inference_across_workers" valuePropName="checked"
valuePropName="checked" style={{ marginBottom: 8 }}
style={{ padding: '0 10px', marginBottom: 0 }} >
noStyle <CheckboxField
> description={intl.formatMessage({
<CheckboxField id: 'models.form.distribution.tips'
description={intl.formatMessage({ })}
id: 'models.form.distribution.tips' label={intl.formatMessage({
})} id: 'resources.form.enableDistributedInferenceAcrossWorkers'
label={intl.formatMessage({ })}
id: 'resources.form.enableDistributedInferenceAcrossWorkers' ></CheckboxField>
})} </Form.Item>
></CheckboxField>
</Form.Item>
</div>
)} )}
<div style={{ paddingBottom: 22 }}> <Form.Item<FormData>
<Form.Item<FormData> name="restart_on_error"
name="restart_on_error" valuePropName="checked"
valuePropName="checked" style={{ marginBottom: 8 }}
style={{ padding: '0 10px', marginBottom: 0 }} >
noStyle <CheckboxField
> description={intl.formatMessage({
<CheckboxField id: 'models.form.restart.onerror.tips'
description={intl.formatMessage({ })}
id: 'models.form.restart.onerror.tips' label={intl.formatMessage({
})} id: 'models.form.restart.onerror'
label={intl.formatMessage({ })}
id: 'models.form.restart.onerror' ></CheckboxField>
})} </Form.Item>
></CheckboxField>
</Form.Item>
</div>
</> </>
); );
}; };
+9
View File
@@ -366,12 +366,21 @@ const DataForm: React.FC<DataFormProps> = forwardRef((props, ref) => {
categories: null, categories: null,
restart_on_error: true, restart_on_error: true,
distributed_inference_across_workers: true, distributed_inference_across_workers: true,
mode: 'throughput',
extended_kv_cache: { extended_kv_cache: {
enabled: false, enabled: false,
chunk_size: null, chunk_size: null,
ram_ratio: 1.2, ram_ratio: 1.2,
ram_size: null ram_size: null
}, },
speculative_config: {
enabled: false,
algorithm: '',
draft_model_name: null,
num_draft_tokens: 3,
ngram_min_match_length: 1,
ngram_max_match_length: 10
},
...initialValues ...initialValues
}} }}
> >
+24 -26
View File
@@ -49,32 +49,30 @@ const KVCacheForm = () => {
return ( return (
<> <>
<div style={{ paddingBottom: 22 }}> <Form.Item<FormData>
<Form.Item<FormData> data-field="extended_kv_cache.enabled"
data-field="extended_kv_cache.enabled" name={['extended_kv_cache', 'enabled']}
name={['extended_kv_cache', 'enabled']} valuePropName="checked"
valuePropName="checked" style={{ marginBottom: 8 }}
style={{ padding: '0 10px', marginBottom: 0 }} extra={
extra={ !builtInBackend && (
!builtInBackend && ( <span
<span dangerouslySetInnerHTML={{
dangerouslySetInnerHTML={{ __html: intl.formatMessage({ id: 'models.form.kvCache.tips' })
__html: intl.formatMessage({ id: 'models.form.kvCache.tips' }) }}
}} ></span>
></span> )
) }
} >
> <CheckboxField
<CheckboxField description={intl.formatMessage({
description={intl.formatMessage({ id: 'models.form.kvCache.tips2'
id: 'models.form.kvCache.tips2' })}
})} disabled={!builtInBackend}
disabled={!builtInBackend} onChange={handleOnChange}
onChange={handleOnChange} label={intl.formatMessage({ id: 'models.form.extendedkvcache' })}
label={intl.formatMessage({ id: 'models.form.extendedkvcache' })} ></CheckboxField>
></CheckboxField> </Form.Item>
</Form.Item>
</div>
{kvCacheEnabled && ( {kvCacheEnabled && (
<> <>
<Form.Item<FormData> name={['extended_kv_cache', 'ram_ratio']}> <Form.Item<FormData> name={['extended_kv_cache', 'ram_ratio']}>
+36 -26
View File
@@ -1,41 +1,51 @@
import SealSelect from '@/components/seal-form/seal-select';
import TooltipList from '@/components/tooltip-list';
import { useIntl } from '@umijs/max'; import { useIntl } from '@umijs/max';
import { Form } from 'antd'; import { Form } from 'antd';
import React from 'react'; import React from 'react';
import { deployFormKeyMap } from '../config';
import { useFormContext } from '../config/form-context';
import KVCacheForm from './kv-cache'; import KVCacheForm from './kv-cache';
import SpeculativeDecode from './speculative-decode';
const flavorTipsList = [
{
title: 'Throughput',
tips: 'models.form.flavor.throughput.tips'
},
{
title: 'Latency',
tips: 'models.form.flavor.latency.tips'
},
{
title: 'Reference',
tips: 'models.form.flavor.reference.tips'
}
];
const Performance: React.FC = () => { const Performance: React.FC = () => {
const intl = useIntl(); const intl = useIntl();
const form = Form.useFormInstance(); const form = Form.useFormInstance();
const { formKey } = useFormContext();
return ( return (
<> <>
<div data-field="extended_kv_cache.enabled"></div>
{formKey === deployFormKeyMap.catalog && (
<Form.Item name="mode">
<SealSelect
description={<TooltipList list={flavorTipsList}></TooltipList>}
label="Mode"
options={[
{ label: 'Throughput', value: 'throughput' },
{ label: 'Latency', value: 'latency' },
{ label: 'Reference', value: 'reference' }
]}
></SealSelect>
</Form.Item>
)}
<KVCacheForm></KVCacheForm> <KVCacheForm></KVCacheForm>
{/* <div style={{ paddingBottom: 22, paddingLeft: 10 }}> <SpeculativeDecode></SpeculativeDecode>
<Form.Item<FormData>
name="optimize_long_prompt"
valuePropName="checked"
style={{ padding: '0 10px', marginBottom: 0 }}
noStyle
>
<CheckboxField
label={intl.formatMessage({ id: 'models.form.optimizeLongPrompt' })}
></CheckboxField>
</Form.Item>
</div>
<div style={{ paddingBottom: 22, paddingLeft: 10 }}>
<Form.Item<FormData>
name="enable_speculative_decoding"
valuePropName="checked"
style={{ padding: '0 10px', marginBottom: 0 }}
noStyle
>
<CheckboxField
label={intl.formatMessage({
id: 'models.form.enableSpeculativeDecoding'
})}
></CheckboxField>
</Form.Item>
</div> */}
</> </>
); );
}; };
@@ -0,0 +1,150 @@
import CheckboxField from '@/components/seal-form/checkbox-field';
import SealInputNumber from '@/components/seal-form/input-number';
import SealInput from '@/components/seal-form/seal-input';
import SealSelect from '@/components/seal-form/seal-select';
import useAppUtils from '@/hooks/use-app-utils';
import { useIntl } from '@umijs/max';
import { Form } from 'antd';
import { useEffect, useState } from 'react';
import { queryDraftModelList } from '../apis';
import { FormData } from '../config/types';
const AlgorithmMap = {
Eagle3: 'eagle3',
MTP: 'mtp',
Ngram: 'ngram'
};
const SpeculativeDecode = () => {
const intl = useIntl();
const { getRuleMessage } = useAppUtils();
const form = Form.useFormInstance();
const speculativeEnabled = Form.useWatch(
['speculative_config', 'enabled'],
form
);
const algorithm = Form.useWatch(['speculative_config', 'algorithm'], form);
const [draftModelList, setDraftModelList] = useState<
Global.BaseOption<string>[]
>([]);
const fetchDraftModels = async () => {
const response = await queryDraftModelList({
page: 1,
perPage: 100
});
const options = response.items.map((item) => ({
label: item.name,
value: item.name
}));
setDraftModelList(options);
};
const handleSpeculativeEnabledChange = (e: any) => {
if (e.target.checked) {
form.setFieldValue('speculative_config', {
enabled: true,
algorithm: AlgorithmMap.Eagle3,
draft_model_name: null,
num_draft_tokens: 3,
ngram_min_match_length: 1,
ngram_max_match_length: 10
});
}
};
useEffect(() => {
if (algorithm === AlgorithmMap.Eagle3) {
fetchDraftModels();
}
}, [algorithm]);
return (
<>
<Form.Item<FormData>
name={['speculative_config', 'enabled']}
valuePropName="checked"
style={{ marginBottom: 8 }}
>
<CheckboxField
label={'Enable Speculative Decoding'}
onChange={handleSpeculativeEnabledChange}
></CheckboxField>
</Form.Item>
{speculativeEnabled && (
<>
<Form.Item<FormData>
name={['speculative_config', 'algorithm']}
rules={[
{
required: true,
message: getRuleMessage('select', 'Algorithm', false)
}
]}
>
<SealSelect
required
label={'Algorithm'}
options={[
{ label: 'Eagle3', value: AlgorithmMap.Eagle3 },
{ label: 'MTP', value: AlgorithmMap.MTP },
{ label: 'N-gram', value: AlgorithmMap.Ngram }
]}
></SealSelect>
</Form.Item>
{algorithm === AlgorithmMap.Eagle3 && (
<Form.Item<FormData>
name={['speculative_config', 'draft_model_name']}
rules={[
{
required: true,
message: getRuleMessage('select', 'Draft Model', false)
}
]}
>
<SealSelect
required
label={'Draft Model'}
options={draftModelList}
></SealSelect>
</Form.Item>
)}
<Form.Item<FormData>
name={['speculative_config', 'num_draft_tokens']}
>
<SealInputNumber
label={'Number of Draft Tokens'}
min={1}
step={1}
precision={0}
/>
</Form.Item>
{algorithm === AlgorithmMap.Ngram && (
<>
<Form.Item<FormData>
name={['speculative_config', 'ngram_min_match_length']}
>
<SealInputNumber
label={'Minimum N-gram Match Length'}
min={1}
step={1}
/>
</Form.Item>
<Form.Item<FormData>
name={['speculative_config', 'ngram_max_match_length']}
>
<SealInput.Input
label={'Maximum N-gram Match Length'}
min={2}
step={1}
/>
</Form.Item>
</>
)}
</>
)}
</>
);
};
export default SpeculativeDecode;
+4 -9
View File
@@ -1,10 +1,10 @@
.catalog-item { .catalog-item {
height: 180px; height: 150px;
overflow: hidden; overflow: hidden;
display: flex; display: flex;
padding: 16px 20px; padding: 16px 20px;
flex-direction: column; flex-direction: column;
justify-content: flex-start; justify-content: space-between;
align-items: flex-start; align-items: flex-start;
border: 1px solid var(--ant-color-border); border: 1px solid var(--ant-color-border);
border-radius: var(--border-radius-base); border-radius: var(--border-radius-base);
@@ -12,15 +12,12 @@
width: 100%; width: 100%;
.img { .img {
width: 32px;
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
height: 100%;
img { img {
height: 32px; height: 32px;
width: 32px;
object-fit: contain; object-fit: contain;
} }
} }
@@ -29,7 +26,6 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: flex-start; justify-content: flex-start;
flex: 1;
width: 100%; width: 100%;
} }
@@ -47,8 +43,7 @@
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: flex-start; justify-content: flex-start;
gap: 12px; gap: 16px;
margin-bottom: 12px;
.name { .name {
flex: 1; flex: 1;
@@ -67,7 +62,7 @@
width: 100%; width: 100%;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 12px; gap: 16px;
justify-content: flex-start; justify-content: flex-start;
align-items: flex-start; align-items: flex-start;
} }