Skip to content
academia.sh

Lesson 04 / 15

Bottleneck Analysis

Tying a load-test result to a layer: measuring application, database and transfer duration separately within a request, attributing three modes that give the same client-side symptom to three separate layers, scanning the attribution rule against a share threshold, and showing with numbers a case where the time genuinely is spent in the right layer but the cause is a different decision.

Contents

The previous lesson’s report reduced the result to a single sentence: p99 37.59 ms, threshold 20, fail. That sentence reports a defect but does not say where it is. The same number could come from a computation in the application layer, a query in the database, or the size of the response, and the fix for each of the three is different. This lesson takes on tying the result to a layer.

Layer Share

A request’s total duration is measured at the client and is a single number. The bottleneck is wherever consumes most of that duration; finding it requires splitting the duration inside the request. The server reads three clocks on every request: before the query, after the query, after the computation, and after the response is written. The gaps between them are the database, application and transfer layers’ durations.

These three numbers alone are not enough, because every layer does some work on every request. What matters is the difference: a baseline run carrying no defect is taken on the same harness, and each layer’s duration is read as its difference from the baseline. Layer share is a layer’s difference divided by the client’s difference.

NF6 (assumption) — to attribute a result to one layer, that layer’s share must be at least 0.50; otherwise the result counts as unattributable. Rationale: below a share of 0.50, even completely eliminating that layer removes less than half the symptom, and the fix decision cannot be defended. The threshold is scanned below.

The decision’s owner is a budget: attribution decides which team and which work the fix goes to. A wrong attribution does not just waste time, it leaves the defect in place. The Performance Anti-Patterns and Monitoring course built ten diagnoses running from symptom to cause; the question asked here comes before that: which layer the result belongs to.

Harness

The loan endpoint runs in five modes. baseline does an indexed query and a small response; db does the same query unindexed; app runs a computation round in the application layer; net serializes and writes a 4,000-row page fetched once at startup on every response; mixed re-fetches the same page from the database on every request. All four are tuned to give the same symptom.

// library/server.mjs — loan endpoint, five modes. Every request measures three layers'
// duration separately: database (query), application (computation), transfer (serialization + writing the response).
// Arguments: <port> <mode baseline|db|app|net|mixed>
import { createServer } from "node:http";
import { DatabaseSync } from "node:sqlite";

const MEMBER = 500, START = 55000, ROUNDS = 29000, ROW = 4000;   // catalog size, computation rounds, page rows
const port = Number(process.argv[2]), mode = process.argv[3];

if (Number.isInteger(port) === false || ["baseline", "db", "app", "net", "mixed"].includes(mode) === false) {
  console.log("usage: node library/server.mjs <port> baseline|db|app|net|mixed");
} else {
  const db = new DatabaseSync(":memory:");
  db.exec("CREATE TABLE loan(id INTEGER PRIMARY KEY, member_no TEXT, book_no TEXT, day INT)");
  if (mode !== "db") db.exec("CREATE INDEX loan_member ON loan(member_no)");
  db.exec("BEGIN");
  const fill = db.prepare("INSERT INTO loan(member_no, book_no, day) VALUES(?,?,?)");
  for (let i = 0; i < START; i += 1) fill.run(`U-${i % MEMBER}`, `K-${i % 9000}`, i % 400);
  db.exec("COMMIT");

  const count = db.prepare("SELECT COUNT(*) AS n FROM loan WHERE member_no = ?");
  const fetch = db.prepare("SELECT id, book_no, day FROM loan LIMIT ?");
  const cache = mode === "net" ? fetch.all(ROW) : [];   // in net mode the page is fetched once at startup
  let requests = 0, dbTime = 0, appTime = 0, netTime = 0, bytes = 0, scan = 0, computed = 0;

  createServer((req, res) => {
    const u = new URL(req.url, "http://local");
    if (u.pathname === "/metrics") {
      res.writeHead(200, { "content-type": "application/json" });
      res.end(JSON.stringify({ requests, dbTime, appTime, netTime, bytes, scan, computed })); return;
    }
    requests += 1;
    const member = u.searchParams.get("member") ?? "U-1";
    const t0 = performance.now();
    const n = count.get(member).n;                    // database layer
    const page = mode === "mixed" ? fetch.all(ROW) : cache;
    scan += mode === "db" ? START : Math.ceil(Math.log2(START));
    const t1 = performance.now();
    let summary = "";                                 // application layer
    if (mode === "app") for (let k = 0; k < ROUNDS; k += 1) { summary = `${member}/${(n + k) % 97}`; computed += 1; }
    const t2 = performance.now();
    const body = JSON.stringify({ member, loan: n, summary, page });   // transfer layer
    dbTime += t1 - t0; appTime += t2 - t1; bytes += body.length;
    res.writeHead(200, { "content-type": "application/json" });
    res.on("finish", () => { netTime += performance.now() - t2; });
    res.end(body);
  }).listen(port);
}

The generator excludes the first second from the measurement and reads the server counters once more at the start of that window, so that both the client and the server side describe the same interval.

// library/measure.mjs — open-loop generator; reads the client duration and the server's
// layer counters. Arguments: <port> <mode> <target rate> <duration s>
import { Agent, request } from "node:http";

const WARMUP = 1;   // the first second is not counted: JIT and cache warm-up
const [port, mode, rate, duration] = process.argv.slice(2).map((x, i) => (i === 1 ? x : Number(x)));
const agent = new Agent({ keepAlive: true, maxSockets: 8192 });
const percentile = (d, p) => { const t = [...d].sort((a, b) => a - b);
  return t[Math.min(t.length - 1, Math.floor((p / 100) * t.length))]; };
let seq = 0;

function oneRequest(samples, sec) {
  const start = performance.now(), i = seq++;
  return new Promise((done) => {
    const r = request({ port, path: `/loan?member=U-${i % 500}`, agent }, (y) => {
      y.resume(); y.on("end", () => { samples.push({ ms: performance.now() - start, sec }); done(); });
    });
    r.on("error", () => { samples.push({ ms: performance.now() - start, sec }); done(); });
    r.end();
  });
}

const metrics = () => new Promise((done) => {
  request({ port, path: "/metrics", agent }, (y) => { let g = "";
    y.on("data", (p) => { g += p; }); y.on("end", () => done(JSON.parse(g))); }).end();
});

if (Number.isInteger(port) === false) {
  console.log("usage: node library/measure.mjs <port> <mode> <target rate> <duration>");
} else {
  const samples = [], inFlight = [];
  let debt = 0, lastT = 0, m1 = null;
  const start = performance.now();
  while (performance.now() - start < duration * 1000) {
    const t = (performance.now() - start) / 1000;
    if (m1 === null && t >= WARMUP) m1 = await metrics();     // the warm-up window closes here
    debt += rate * (t - lastT); lastT = t;
    while (debt >= 1) { debt -= 1; inFlight.push(oneRequest(samples, t)); }
    await new Promise((c) => setTimeout(c, 5));
  }
  await Promise.all(inFlight);
  const m2 = await metrics(), n = m2.requests - m1.requests;
  const d = samples.filter((r) => r.sec >= WARMUP).map((r) => r.ms);
  console.log(JSON.stringify({ mode, rate, samples: d.length,
    client: d.reduce((a, b) => a + b, 0) / d.length, p95: percentile(d, 95),
    db: (m2.dbTime - m1.dbTime) / n, app: (m2.appTime - m1.appTime) / n, net: (m2.netTime - m1.netTime) / n,
    bytes: (m2.bytes - m1.bytes) / n, scan: (m2.scan - m1.scan) / n }));
}
agent.destroy();
// library/attribution.mjs — computes layer shares against the baseline, ties attribution to
// a rule, and scans the share threshold. Argument: <jsonl>
import { readFileSync } from "node:fs";

const SHARE = 0.5;                                  // NF6: minimum share to attribute to one layer
const ACTUAL = { db: "database", app: "application", net: "transfer", mixed: "transfer" };
const LAYER = ["db", "app", "net"], NAME = { db: "database", app: "application", net: "transfer" };
const s = readFileSync(process.argv[2], "utf8").trim().split("\n").map((x) => JSON.parse(x));
const find = (k) => s.find((r) => r.mode === k);
const BASE = find("baseline"), MODE = ["db", "app", "net", "mixed"];
const fmt = (x, n = 3) => x.toFixed(n);
const pad = (x, n) => String(x).padStart(n);
const diff = (r) => ({ client: r.client - BASE.client, db: r.db - BASE.db, app: r.app - BASE.app, net: r.net - BASE.net });
const attribute = (r, threshold) => {               // if the largest share crosses the threshold, that layer
  const f = diff(r);
  const top = LAYER.reduce((a, k) => (f[k] > f[a] ? k : a), "db");
  return f[top] / f.client >= threshold ? NAME[top] : "unattributable";
};

console.log(`baseline: client ${fmt(BASE.client)} ms, db ${fmt(BASE.db)}, app ${fmt(BASE.app)}, transfer ${fmt(BASE.net)}, ` +
  `bytes ${Math.round(BASE.bytes)}`);
console.log("mode    client   p95   diff   db share  app share  transfer share  bytes    attribution");
for (const k of MODE) {
  const r = find(k), f = diff(r);
  console.log(k.padEnd(8) + pad(fmt(r.client, 2), 7) + pad(fmt(r.p95, 2), 6) + pad(fmt(f.client, 2), 7) +
    pad(fmt(f.db / f.client, 2), 9) + pad(fmt(f.app / f.client, 2), 10) + pad(fmt(f.net / f.client, 2), 14) +
    pad(Math.round(r.bytes), 9) + "  " + attribute(r, SHARE));
}

console.log("\nshare-threshold scan: the four modes' attribution (actual layer: db=database, " +
  "app=application, net and mixed=transfer)");
console.log("share threshold  correct /4  wrong /4  unattributable /4");
for (const e of [0.3, 0.5, 0.7, 0.8, 1.0]) {
  const a = MODE.map((k) => attribute(find(k), e));
  const correct = a.filter((x, i) => x === ACTUAL[MODE[i]]).length;
  const none = a.filter((x) => x === "unattributable").length;
  console.log(fmt(e, 1).padEnd(17) + pad(correct, 11) + pad(4 - correct - none, 10) + pad(none, 18));
}
# measure.sh — five runs: baseline and four modes, at the same target rate, each with a fresh server.
rm -f result.jsonl
for M in baseline db app net mixed; do
  node library/server.mjs 8854 $M & SERVER=$!
  sleep 1.2
  node library/measure.mjs 8854 $M 250 6 >> result.jsonl
  kill $SERVER
  wait $SERVER 2>/dev/null
done
node library/attribution.mjs result.jsonl
baseline: client 0.629 ms, db 0.032, app 0.000, transfer 0.108, bytes 56
mode    client   p95   diff   db share  app share  transfer share  bytes    attribution
db         2.11  3.45   1.48     0.98     -0.00         -0.06       56  database
app        1.49  2.60   0.86    -0.01      0.90         -0.03       63  application
net        1.51  2.74   0.88    -0.03     -0.00          0.98   160738  transfer
mixed      2.16  3.62   1.53     0.55     -0.00          0.35   160738  database

share-threshold scan: the four modes' attribution (actual layer: db=database, app=application, net and mixed=transfer)
share threshold  correct /4  wrong /4  unattributable /4
0.3                        3         1                 0
0.5                        3         1                 0
0.7                        3         0                 1
0.8                        3         0                 1
1.0                        0         0                 4

Same Symptom, Three Separate Layers

The first column is the decision itself, and it is the same across all four modes. In this run the client average ranges from 1.49 to 2.16 ms — about 45% higher at the top than at the bottom; p95 ranges from 2.60 to 3.62 ms across the four modes. A report that looks only at the client-side number cannot tell these four structures apart.

The share columns tell them apart. In db mode the database share is 0.98, in app mode the application share is 0.90, in net mode the transfer share is 0.98. Three modes give the same symptom and get attributed to three separate layers; their fixes are different too — adding an index, thinning the computation, shrinking the response.

None of the shares in this run exceed 1, but the measurement allows it: if the database difference came out larger than the client difference, that would show the baseline’s own noise growing relative to a small difference between two runs. The rule only looks at the largest share, so the attribution would not change; a share over 1 just means the reading is “the database explains the entire symptom,” nothing more.

When Attribution Is Wrong

The fourth row is where the rule breaks. In mixed mode the database share is 0.55, the transfer share is 0.35; the rule blames the database. The measurement is correct: time really is spent in the query, because the query reads 4,000 rows. The result is still wrong.

The evidence is in the last column, and it is independent of the run: mixed and net send the same 160,738 bytes per response. In net mode that page was fetched once at startup and cached, so the database share is near zero and the rule blames transfer. The difference between the two modes is not in the query, it is in the decision about the size of the response — and that decision is the same in both. Adding an index to the database in mixed mode changes nothing; the query does not need an index, it reads 4,000 rows regardless.

The rule is this: layer share says where the time went, not which decision produced it. The measurement that answers the second question is a run-independent quantity — bytes sent, rows read, records scanned. In this run db mode scans 55,000 rows per request, the others scan 16; net and mixed send 160,738 bytes per request, the others send 56 and 63. Wherever two layers give the same symptom, this is the measurement that tells them apart.

Share Threshold Scan

The second table shifts NF6’s 0.50. The table’s individual numbers are run-dependent — a share is drawn from measured duration differences and moves from run to run on this machine — but its direction is run-independent and the decision is made in that direction. At a low threshold all four modes get attributed to a layer and mixed gets a wrong attribution; as the threshold rises, the wrong attribution clears first, then the correct ones start falling under the threshold too and turn unattributable. In this run the turn starts at 0.7, where mixed drops out; the other three hold their attribution up to just under 1.0, since their own shares (0.98, 0.90, 0.98) sit close to the ceiling, and at exactly 1.0 none of them clears it either — in another run the turn could start a step earlier or later, because what crosses the threshold is the share itself.

This is the trade-off between the two error types. A low threshold blames an innocent layer; a high threshold cannot pin the defect on anyone, and the finding closes without becoming a record. NF6’s 0.50 does not clear the wrong attribution by itself — what clears it is not the threshold but reading the run-independent quantity.

Cost and the Class It Misses

The run-dependent side of the cost depends on the environment: five runs finished in under forty seconds on this machine. The run-independent side is countable — five server processes, five of every six seconds measured per run (the first second is set aside for warm-up), and every run reads four clocks per request, meaning instrumentation adds four extra measurements per request.

The class it misses can also be counted: this harness only splits duration inside the server. The residual between the client’s difference and the sum of the measured layers — about 14% of the difference in app mode — is written to no layer. Network round trip, event-loop wait and the operating system’s socket work stay there. In a system approaching saturation this residual grows and share-based attribution stops being usable; the metric to read then is not layer share but the previous lesson’s throughput–latency pair.

Summary

  • Layer share is a layer’s difference from the baseline run divided by the client’s difference; the attribution rule requires the largest share to cross NF6’s 0.50.
  • The same client-side symptom (average 1.49–2.16 ms) got attributed to three separate layers: database at a share of 0.98, application at 0.90, transfer at 0.98.
  • Attribution is wrong in mixed mode: time really is spent in the query, but it sends the same 160,738 bytes as net; the cause is not the query, it is the decision about response size.
  • The share-threshold scan shows the trade-off: as the threshold rises, the wrong attribution clears first, then correct attributions fall too and unattributable runs climb; which step the turn happens at is run-dependent.
  • Layer share says where the time went; the run-independent quantity says which decision produced it (55,000 rows scanned, 160,738 bytes sent).

Next Step

Every number up to this point was read from a single run. This lesson’s baseline was taken once and every share was divided by it; the layer-share residual not fully summing to the client’s own difference — about 14% left over in app mode — shows that two runs of the same harness do not give the same number. And the harness excludes the first second from measurement altogether — the existence of a warm-up effect was assumed but its size was never measured. The next lesson puts the measurement itself to the test: how many runs warm-up lasts, how much variability exists between runs of the same structure, and how many runs a difference between two structures needs before it can be called meaningful.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close