diff --git a/simulator/README.md b/simulator/README.md index afaf2983..55589af5 100644 --- a/simulator/README.md +++ b/simulator/README.md @@ -150,9 +150,11 @@ python -m simulator sweep \ | `length_mix` | `{tokens,weight}[]` | 持续模式的离散长度分布;`weight` 只需为正数,不要求预归一化。 | | `csv_path` | `string|null` | CLI 读取的 CSV 路径;与 `csv_text` 互斥。页面上传会使用 `csv_text`。 | | `csv_text` | `string|null` | CSV 原始内容;与 `csv_path` 互斥。 | -| `csv_sampling` | `cycle|sample` / `"cycle"` | 无时间戳 CSV 在持续模式下的选取方式:按行循环或有放回随机采样。 | +| `csv_sampling` | `cycle|sample` / `"cycle"` | CSV 在合成到达过程下的长度选取方式:按行循环或有放回随机采样。 | -优先级:CSV > `fixed_lengths`/`length_mix`。固定模式下 CSV 每行回放一次;持续模式下无时间戳 CSV 提供经验长度分布。 +优先级:CSV > `fixed_lengths`/`length_mix`。固定模式下 CSV 每行回放一次; +持续模式下,`constant`/`poisson` 把 CSV 当作经验长度分布,即使文件带 +`arrival_time_ms` 也会忽略该列;只有 `trace`/`scaled_trace` 会消费时间戳。 WebUI 还提供 MoonConv V4 Flash 的 `formal_0/1/2`(各 512 条)和 `screening`(128 条)内置 trace。它们只保留输入长度与相对到达时间;选择后 @@ -177,6 +179,10 @@ WebUI 还提供 MoonConv V4 Flash 的 `formal_0/1/2`(各 512 条)和 配置值。QPS sweep 可以使用 `scaled_trace`,每个扫描点重新缩放同一 arrival 形状。 +WebUI 上传带 `arrival_time_ms` 的 CSV 时默认切换到持续模式和 +`scaled_trace`。仍可手动切换为 `trace` 精确回放,或切换为 +`constant`/`poisson` 仅使用其长度分布。 + ### 2.3 `scheduler` | 字段 | 类型 / 默认值 | 说明 | diff --git a/simulator/tests/test_server.py b/simulator/tests/test_server.py index 17ce5206..3adaccf2 100644 --- a/simulator/tests/test_server.py +++ b/simulator/tests/test_server.py @@ -187,6 +187,8 @@ def test_defaults_page_and_simulation_api(self) -> None: self.assertIn("data.length_datasets.forEach", page) self.assertIn("/api/length-datasets/", page) self.assertIn('value="scaled_trace"', page) + self.assertIn("function useTimestampedCsvDefaults", page) + self.assertIn("columns.includes('arrival_time_ms')", page) self.assertIn("item.num_devices===afd.num_devices", page) self.assertIn('value="prefill_token_greedy"', page) self.assertIn('value="prefill_token_square_greedy"', page) diff --git a/simulator/tests/test_workload.py b/simulator/tests/test_workload.py index fc7a7143..f736094a 100644 --- a/simulator/tests/test_workload.py +++ b/simulator/tests/test_workload.py @@ -104,6 +104,33 @@ def test_scaled_trace_keeps_zero_gap_arrivals_simultaneous(self) -> None: self.assertEqual([request.arrival_ms for request in workload], [0.0, 0.0]) + def test_synthetic_arrival_uses_timestamped_csv_as_length_pool(self) -> None: + config = SimulationConfig.from_mapping( + { + "mode": "continuous", + "csv_text": ( + "arrival_time_ms,input_length\n" + "100,128\n" + "125,256\n" + "160,512\n" + ), + "arrival": { + "kind": "constant", + "qps": 2, + "duration_s": 1, + "warmup_s": 0, + }, + } + ) + + workload = generate_workload(config) + + self.assertEqual([request.arrival_ms for request in workload], [0.0, 500.0]) + self.assertEqual( + [request.input_tokens for request in workload], + [128, 256], + ) + def test_prefix_cache_sampling_is_deterministic_and_block_aligned(self) -> None: raw = { "mode": "fixed", diff --git a/simulator/web/index.html b/simulator/web/index.html index bd722fe4..f1327abe 100644 --- a/simulator/web/index.html +++ b/simulator/web/index.html @@ -334,11 +334,21 @@

仿真参数

$('profile').textContent = `${metadata.model || 'DSV4-Flash'} · ${metadata.device || 'device unknown'} · ${afd.num_devices} die · ${afd.layer_count} 层`; } + function useTimestampedCsvDefaults(text) { + const header = text.replace(/^\ufeff/, '').split(/\r?\n/, 1)[0]; + const columns = header.split(',').map(column => column.trim()); + if (!columns.includes('arrival_time_ms')) return; + $('mode').value = 'continuous'; + $('arrival-kind').value = 'scaled_trace'; + } + $('csv-file').addEventListener('change', async event => { const file = event.target.files[0]; csvText = file ? await file.text() : null; $('length-dataset').value = ''; $('csv-status').textContent = file ? `${file.name} · ${(file.size / 1024).toFixed(1)} KiB` : '未加载 CSV'; + if (csvText) useTimestampedCsvDefaults(csvText); + updateControlState(); }); $('length-dataset').addEventListener('change', async event => { @@ -356,8 +366,7 @@

仿真参数

const response = await fetch(`/api/length-datasets/${encodeURIComponent(datasetId)}`); if (!response.ok) throw new Error(`加载长度数据集失败:HTTP ${response.status}`); csvText = await response.text(); - $('mode').value = 'continuous'; - $('arrival-kind').value = 'scaled_trace'; + useTimestampedCsvDefaults(csvText); $('csv-status').textContent = `${dataset.label} · ${dataset.request_count} 条 · ${(csvText.length / 1024).toFixed(1)} KiB`; updateControlState(); } catch (error) { diff --git a/simulator/workload.py b/simulator/workload.py index 28efd7d9..123b17e0 100644 --- a/simulator/workload.py +++ b/simulator/workload.py @@ -129,7 +129,11 @@ def generate_workload(config: SimulationConfig) -> tuple[RequestSpec, ...]: ] return _materialize_requests(raw_requests, arrivals, config.prefix_cache) - if csv_requests and all(item.arrival_time_ms is not None for item in csv_requests): + if ( + csv_requests + and config.arrival.kind in {"trace", "scaled_trace"} + and all(item.arrival_time_ms is not None for item in csv_requests) + ): if config.arrival.kind == "scaled_trace": raw_requests, arrivals = _scale_and_repeat_trace( csv_requests, @@ -141,11 +145,6 @@ def generate_workload(config: SimulationConfig) -> tuple[RequestSpec, ...]: arrivals, config.prefix_cache, ) - if config.arrival.kind != "trace": - raise ValueError( - "CSV arrival_time_ms requires arrival.kind=trace for exact replay " - "or scaled_trace for QPS-scaled replay" - ) first_arrival = float(csv_requests[0].arrival_time_ms or 0.0) arrivals = [ float(item.arrival_time_ms or 0.0) - first_arrival for item in csv_requests