Files

569 lines
21 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Latency & Throughput Comparison</title>
<script src="https://d3js.org/d3.v7.min.js"></script>
<!-- Tippy.js -->
<link rel="stylesheet" href="https://unpkg.com/tippy.js@6/dist/tippy.css">
<script src="https://unpkg.com/@popperjs/core@2"></script>
<script src="https://unpkg.com/tippy.js@6"></script>
<style>
:root {
--bg: #000000;
--text: #FFFFFF;
--brand-yellow: #EFFB5E;
--accent-cyan: #71BFED;
--accent-magenta: #E373EC;
--accent-lime: #70E350;
--accent-orange: #DB7B40;
--grid-light: #DADAD9;
--grid-mid: #9FA19C;
}
body {
margin: 0;
font-family: system-ui, sans-serif;
background: var(--bg);
color: var(--text);
}
header {
display: flex;
gap: 1rem;
align-items: center;
padding: 10px 14px;
flex-wrap: wrap;
position: relative;
padding-right: 260px; /* reserve space so legend doesn't overlap controls */
}
select {
background: var(--bg);
color: var(--text);
border: 1px solid var(--grid-mid);
padding: 4px 6px;
}
.legend {
position: fixed;
top: 8px;
right: 14px;
z-index: 10;
display: flex;
flex-wrap: wrap;
gap: 6px;
padding: 4px 8px;
background: rgba(0, 0, 0, 0.55);
border: 1px solid var(--grid-mid);
border-radius: 4px;
}
.legend-item {
display: flex;
align-items: center;
gap: 4px;
font-size: 12px;
}
.legend-swatch {
width: 16px;
height: 14px;
border: 1px solid var(--grid-mid);
}
.panes {
display: flex;
flex-wrap: wrap;
}
.pane {
flex: 1 1 620px;
min-width: 480px;
border: none;
}
.pane-header {
padding: 6px 10px;
font-weight: 600;
font-size: 14px;
background: rgba(255, 255, 255, 0.04);
display: flex;
justify-content: center;
position: relative;
}
.pane-header .status {
position: absolute;
right: 10px;
top: 6px;
}
.charts {
display: flex;
flex-direction: column;
gap: 12px;
padding: 6px 10px 24px;
}
svg {
width: 100%;
overflow: visible;
font-size: 11px;
}
.axis path,
.axis line {
stroke: var(--grid-mid);
}
.axis text {
fill: var(--text);
}
.bar-seg {
stroke: none;
}
.qps-dot {
fill: var(--brand-yellow);
}
.qps-line {
stroke: var(--brand-yellow);
}
.status {
font-size: 11px;
color: var(--grid-mid);
margin-left: 8px;
}
.grid-line {
stroke: var(--grid-light);
stroke-width: 1;
opacity: .25;
}
/* Tippy theme */
.tippy-box[data-theme~='ch-dark'] {
background: var(--bg);
color: var(--text);
border: 1px solid var(--grid-mid);
font-size: 12px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.6);
}
.tippy-box[data-theme~='ch-dark'] .tippy-content {
padding: 6px 8px;
line-height: 1.25;
}
</style>
</head>
<body>
<header>
<label for="querySelect">Query:</label>
<select id="querySelect"></select>
<span id="info"></span>
</header>
<div id="legend" class="legend"></div>
<div class="panes">
<div class="pane" id="pane-left">
<div class="pane-header">
<select id="left-dataset"></select>
<span class="status" id="left-status"></span>
</div>
<div class="charts">
<svg id="left-latency"></svg>
</div>
</div>
<div class="pane" id="pane-right">
<div class="pane-header">
<select id="right-dataset"></select>
<span class="status" id="right-status"></span>
</div>
<div class="charts">
<svg id="right-latency"></svg>
</div>
</div>
</div>
<script>
// (() => {
'use strict';
// ---------------------- Config ----------------------
const DATASET_FILES = [
"no-concurrency-control.json",
"concurrency-control.json"
];
const REQUIRED_POINTS = 32; // minimum required data rows (after filtering invalid rows)
const PCTS = ["0", "10", "20", "30", "40", "50", "60", "70", "80", "90", "95", "99", "99.9", "99.99"];
const DRAW_PCTS = PCTS.filter(p => parseFloat(p) <= 99 && parseFloat(p) != 95);
// New: derived constants and formatters
const TOP_PCT = DRAW_PCTS[DRAW_PCTS.length - 1];
const fmtQps = d3.format(",.1f");
const fmtMs = d3.format(".3f");
const fmtMsAxis = d3.format(",.0f"); // axis-friendly ms
// Build colorScale using CSS variables (single source of color truth)
const css = getComputedStyle(document.documentElement);
const colorScale = d3.scaleLinear()
.domain([0, 0.25, 0.5, 0.75, 1])
.range([
css.getPropertyValue('--bg').trim(),
css.getPropertyValue('--accent-lime').trim(),
css.getPropertyValue('--brand-yellow').trim(),
css.getPropertyValue('--accent-orange').trim(),
css.getPropertyValue('--accent-magenta').trim()
]);
const margin = { top: 30, right: 70, bottom: 44, left: 70 };
const latencyHeight = 340; // unified chart height
// ---------------------- State -----------------------
let datasetData = {};
let leftDatasetId = null;
let rightDatasetId = null;
let currentKey = null;
// ---------------------- Legend ----------------------
function buildLegend() {
const lg = d3.select("#legend").html("");
// Skip P0 in the legend (we don't render a P0 segment)
DRAW_PCTS.filter(p => p !== "0").forEach(p => {
lg.append("div").attr("class", "legend-item")
.html(`<div class="legend-swatch" style="background:${colorScale(parseFloat(p) / 100)}"></div>P${p}`);
});
}
// ---------------------- Data Prep -------------------
function prepareRows(rows) {
if (!rows) return [];
return rows
.filter(r => r[0] > 0)
.sort((a, b) => d3.ascending(a[0], b[0]))
.map(r => {
const conc = r[0];
const qps = r[1];
const percentiles = {};
PCTS.forEach((p, i) => {
let v = r[2 + i];
// convert seconds -> milliseconds
if (v != null) v = v * 1000;
percentiles[p] = v;
});
return { conc, qps, percentiles };
});
}
function getDataForKey(raw, key) { return prepareRows(raw[key]); }
// Helper: strip ".log" suffix from keys
function normalizeKeys(obj) {
const out = {};
Object.keys(obj || {}).forEach(k => {
const nk = k.replace(/\.log$/, '');
out[nk] = obj[k];
});
return out;
}
// ---------------------- Rendering -------------------
function renderAll() {
if (!leftDatasetId || !rightDatasetId) return;
const leftDataRaw = datasetData[leftDatasetId]?.raw || {};
const rightDataRaw = datasetData[rightDatasetId]?.raw || {};
if (!currentKey) return;
const leftData = getDataForKey(leftDataRaw, currentKey);
const rightData = getDataForKey(rightDataRaw, currentKey);
const latencyMax = d3.max([
leftData.length ? d3.max(leftData, d => d.percentiles[TOP_PCT]) : 0,
rightData.length ? d3.max(rightData, d => d.percentiles[TOP_PCT]) : 0
]) || 1;
const qpsMax = d3.max([
leftData.length ? d3.max(leftData, d => d.qps) : 0,
rightData.length ? d3.max(rightData, d => d.qps) : 0
]) || 1;
const leftStatus = datasetData[leftDatasetId]?.status || "ok";
const rightStatus = datasetData[rightDatasetId]?.status || "ok";
renderPane({
key: currentKey, raw: leftDataRaw,
svg: d3.select("#left-latency"),
statusSel: d3.select("#left-status"),
latencyMax, qpsMax, statusStr: leftStatus
});
renderPane({
key: currentKey, raw: rightDataRaw,
svg: d3.select("#right-latency"),
statusSel: d3.select("#right-status"),
latencyMax, qpsMax, statusStr: rightStatus
});
}
function renderPane({ key, raw, svg, statusSel, latencyMax, qpsMax, statusStr }) {
const data = getDataForKey(raw, key);
if (!data.length) {
svg.selectAll("*").remove();
statusSel.text(statusStr === "error" ? "Load failed" : "No data");
return;
}
statusSel.text(statusStr === "error" ? "Load failed" : "");
const x = d3.scaleBand()
.domain(data.map(d => d.conc))
.paddingInner(0.2)
.paddingOuter(0.05);
function resize() {
const width = svg.node().clientWidth;
x.range([margin.left, width - margin.right]);
drawCombined(svg, data, x, width, key, latencyMax, qpsMax);
}
// Removed per-pane resize listener; rely on global renderAll
resize();
}
function drawCombined(svg, data, x, width, key, latencyMax, qpsMax) {
svg
.attr("height", latencyHeight + margin.top + margin.bottom)
.selectAll("*").remove();
// Scales
const yLat = d3.scaleLinear()
.domain([0, latencyMax]).nice()
.range([latencyHeight + margin.top, margin.top]);
const yQps = d3.scaleLinear()
.domain([0, qpsMax]).nice()
.range([latencyHeight + margin.top, margin.top]);
// Grid lines (QPS axis)
svg.append("g")
.selectAll("line")
.data(yQps.ticks(10))
.join("line")
.attr("class", "grid-line")
.attr("x1", margin.left)
.attr("x2", width - margin.right)
.attr("y1", d => yQps(d))
.attr("y2", d => yQps(d));
// X axis
svg.append("g")
.attr("class", "axis")
.attr("transform", `translate(0,${latencyHeight + margin.top})`)
.call(d3.axisBottom(x).tickSizeOuter(0));
// Left axis: QPS
svg.append("g")
.attr("class", "axis")
.attr("transform", `translate(${margin.left},0)`)
.call(d3.axisLeft(yQps).ticks(10).tickFormat(fmtQps));
// Right axis: Latency (ms)
svg.append("g")
.attr("class", "axis")
.attr("transform", `translate(${width - margin.right},0)`)
.call(d3.axisRight(yLat).ticks(10).tickFormat(d => fmtMsAxis(d)));
// Title
svg.append("text")
.attr("x", width / 2)
.attr("y", 18)
.attr("fill", "var(--text)")
.attr("font-weight", "bold")
.attr("text-anchor", "middle")
.text(`${key} performance`);
// Axis labels
svg.append("text")
.attr("x", margin.left - 50)
.attr("y", margin.top - 18)
.attr("fill", "var(--grid-mid)")
.attr("font-size", "10px")
.text("Throughput, QPS");
svg.append("text")
.attr("x", width - margin.right + 40)
.attr("y", margin.top - 18)
.attr("fill", "var(--grid-mid)")
.attr("font-size", "10px")
.attr("text-anchor", "end")
.text("Latency, ms");
// X-axis label (moved down)
svg.append("text")
.attr("x", width / 2)
.attr("y", latencyHeight + margin.top + 36)
.attr("fill", "var(--grid-mid)")
.attr("font-size", "10px")
.attr("text-anchor", "middle")
.text("Concurrent queries");
// Latency stacked percentile bars (ms)
const barWidth = x.bandwidth();
const g = svg.append("g");
data.forEach(d => {
const base = d.percentiles[DRAW_PCTS[0]];
if (base == null) return; // guard missing base percentile
let prev = base;
DRAW_PCTS.slice(1).forEach(p => {
const val = d.percentiles[p];
if (val == null || val < prev) return;
const h = yLat(prev) - yLat(val);
if (h <= 0) { prev = val; return; }
g.append("rect")
.attr("class", "bar-seg")
.attr("x", x(d.conc))
.attr("width", barWidth)
.attr("y", yLat(val))
.attr("height", h)
.attr("fill", colorScale(parseFloat(p) / 100))
.attr("data-tippy-content",
`Concurrency: ${d.conc}<br>P${p}: ${fmtMs(val)} ms`
);
prev = val;
});
});
// QPS line + dots
const bw = x.bandwidth();
const line = d3.line()
.x(d => x(d.conc) + bw / 2)
.y(d => yQps(d.qps))
.curve(d3.curveMonotoneX);
svg.append("path")
.attr("fill", "none")
.attr("class", "qps-line")
.attr("stroke-width", 2)
.attr("d", line(data));
svg.selectAll(".qps-dot")
.data(data)
.join("circle")
.attr("class", "qps-dot")
.attr("cx", d => x(d.conc) + bw / 2)
.attr("cy", d => yQps(d.qps))
.attr("r", 3)
.attr("data-tippy-content", d =>
`Concurrency: ${d.conc}<br>QPS: ${fmtQps(d.qps)}`
);
// Initialize a single delegated Tippy per SVG (avoids per-element instances)
if (window.tippy && !svg.node().__tippyDelegated) {
tippy.delegate(svg.node(), {
target: '[data-tippy-content]',
allowHTML: true,
theme: 'ch-dark',
animation: 'none',
appendTo: document.body,
maxWidth: 320,
delay: [0, 0],
duration: [0, 0]
});
svg.node().__tippyDelegated = true;
}
}
// ---------------------- Dataset / Query Selectors ---------------
function datasetIdFromPath(p) {
const parts = p.split("/").filter(Boolean);
return parts.length >= 2 ? parts[parts.length - 2] : p.replace(".json", "");
}
function buildDatasetSelects() {
const ids = Object.keys(datasetData);
const leftSel = d3.select("#left-dataset").html("");
const rightSel = d3.select("#right-dataset").html("");
ids.forEach(id => {
leftSel.append("option").attr("value", id).text(id);
rightSel.append("option").attr("value", id).text(id);
});
if (!leftDatasetId) {
// Prefer specific defaults if present
leftDatasetId = ids.find(i => i === "no-control") || ids[0];
rightDatasetId = ids.find(i => i === "concurrency-control-16") || ids[1] || ids[0];
}
leftSel.property("value", leftDatasetId);
rightSel.property("value", rightDatasetId);
leftSel.on("change", e => { leftDatasetId = e.target.value; updateQueries(); });
rightSel.on("change", e => { rightDatasetId = e.target.value; updateQueries(); });
}
function updateQueries() {
const leftRaw = datasetData[leftDatasetId]?.raw || {};
const rightRaw = datasetData[rightDatasetId]?.raw || {};
const leftKeys = Object.keys(leftRaw);
const rightKeys = Object.keys(rightRaw);
let keys = leftKeys.filter(k => rightKeys.includes(k));
if (!keys.length) keys = Array.from(new Set([...leftKeys, ...rightKeys]));
keys.sort((a, b) => {
const na = parseInt(a.replace(/[^\d]/g, ''), 10);
const nb = parseInt(b.replace(/[^\d]/g, ''), 10);
return (isNaN(na) || isNaN(nb)) ? d3.ascending(a, b) : d3.ascending(na, nb);
});
populateSelect(keys);
}
function populateSelect(keys) {
const sel = d3.select("#querySelect").html("");
keys.forEach(k => sel.append("option").attr("value", k).text(k));
sel.on("change", e => { currentKey = e.target.value; renderAll(); });
if (!keys.length) {
currentKey = null;
renderAll();
return;
}
if (!currentKey || !keys.includes(currentKey)) currentKey = keys[0];
sel.property("value", currentKey);
renderAll();
}
// ---------------------- Init ------------------------
function init() {
buildLegend();
Promise.allSettled(
DATASET_FILES.map(url => fetch(url).then(r => r.json()))
).then(results => {
results.forEach((res, idx) => {
const url = DATASET_FILES[idx];
const id = datasetIdFromPath(url);
if (res.status === "fulfilled") {
let rawAll = normalizeKeys(res.value);
// Filter: drop keys with fewer than REQUIRED_POINTS valid rows.
const filtered = {};
Object.keys(rawAll).forEach(k => {
const rows = Array.isArray(rawAll[k]) ? rawAll[k] : [];
// count only rows we would later keep (r[0] > 0)
const validCount = rows.filter(r => r && r[0] > 0).length;
if (validCount >= REQUIRED_POINTS) filtered[k] = rawAll[k];
});
// Fallback: if everything filtered out, keep original so UI not empty.
const raw = Object.keys(filtered).length ? filtered : rawAll;
datasetData[id] = { url, raw, status: "ok" };
} else {
datasetData[id] = { url, raw: {}, status: "error" };
}
});
buildDatasetSelects();
updateQueries();
window.addEventListener("resize", renderAll);
// Status text now handled dynamically in renderPane
}).catch(err => {
d3.select("#info").text("Init error: " + err);
});
}
init();
// })(); // end IIFE
</script>
</body>
</html>