Skip to content

The Hot-Account Ceiling: Amdahl and Local Fee Markets

The last three pages built a machine that runs faster the more independent work you feed it. Scheduling turns a stream of transactions into conflict-free batches; executing a batch fans those batches across cores. When the accounts are disjoint, the whole thing behaves like an ideal parallel machine: throw more cores at it, get more throughput.

This page is the honest counterweight. There is a workload that defeats the entire design, and it is embarrassingly simple: one account that every transaction writes. Not a bug, not a missed optimization — a wall the model cannot climb, no matter how many cores you own. We are going to make that wall precise with Amdahl’s law, and then watch Solana’s real-world response — priority fees and local fee markets — fall straight out of the shape of the wall. The punchline of this part is not “Sealevel is fast.” It is: Sealevel makes where your writes land a performance decision, and this page is where you learn to read that decision.

Recall the scheduling rule from Scheduling Into Conflict-Free Batches: a transaction goes in the earliest batch strictly after every earlier transaction it conflicts with, and two transactions conflict when they share an account and at least one writes it.

Now feed the scheduler N transactions that all increment the same counter account:

rust/solmini/src/runtime.rs
let counter = Pubkey::from_seed("global-counter");
let txs: Vec<Transaction> = (0..N)
.map(|_| Transaction::increment(counter, 1)) // every tx writes `counter`
.collect();
let batches = schedule(&txs);

Transaction::increment declares the counter as writable. So transaction i conflicts with every earlier transaction 0..i — they all fight over the one writable account. The layering rule then forces each into its own batch:

accounts written: C C C C C ... (all the same account)
batch 0: t0[C] ← one transaction
batch 1: t1[C] ← waited on t0
batch 2: t2[C] ← waited on t1
batch 3: t3[C] ← waited on t2
...
batch N-1: t(N-1)[C]
→ N batches, each with exactly ONE transaction. Batches run one after another.

The scheduler produced N single-transaction batches. The companion crate pins this down as a test — increments to one counter serialize completely, and the final count is still exactly right:

// 50 increments to ONE counter → 50 single-tx batches (fully serialized).
let batches = schedule(&txs);
assert_eq!(batches.len(), 50); // no two share a batch
assert!(batches.iter().all(|b| b.len() == 1));

Look at what a 64-core machine does with this: batch 0 runs one transaction on one core; the other 63 cores sit idle. Then batch 1 runs one transaction on one core; 63 idle. And so on, N times. The parallel runtime has, correctly, degenerated into the serial runtime. The contended account, not the core count, is the ceiling.

This is not the scheduler being timid. It is the scheduler being right. Two increments to the same counter are a read-modify-write; if they ran at the same time, one update would be lost — the exact lost-update race the whole account-locks design exists to prevent. Serializing them is the only correct answer. The cost of correctness here is that correctness is serial.

We can quantify precisely how much parallelism a workload can ever have. Amdahl’s law (Gene Amdahl, 1967) splits any job into a fraction s that must run serially and a fraction 1 - s that can be perfectly parallelized. With p processors:

speedup(p) = ──────────────────
s + (1 - s)/p
As p → ∞: speedup → 1/s

The second line is the whole lesson. The parallel part shrinks toward zero as you add cores, but the serial part s never shrinks — so the best speedup you can ever reach, with infinite cores, is 1/s. The serial fraction sets a hard ceiling that no hardware buys past.

Map the two extremes of Sealevel onto it:

all-independent work (s ≈ 0): speedup ≈ p ← near-linear; more cores, more throughput
one hot account (s = 1): speedup = 1 ← 1/s = 1; cores do nothing

The N-increments workload is s = 1: every unit of work is serial, because every transaction depends on the previous one through the shared writable account. Plug it in: speedup = 1/(1 + 0) = 1. Zero gain, forever. A workload of N independent transfers is the mirror image — s ≈ 0, so speedup tracks the core count. Real workloads live between the two, and the serial fraction is set by how much of the traffic collides on the same writable accounts.

Under the hood — why “just shard the counter” is a design, not a config

Section titled “Under the hood — why “just shard the counter” is a design, not a config”

The instinct on hitting this wall is “make the scheduler smarter.” It can’t help: two writes to one account are a genuine data dependency, and Amdahl’s s counts dependencies, not scheduler cleverness. The only way to lower s is to change what the transactions write — which is application design, not runtime tuning.

The standard move is sharding the hot account: instead of one counter every writer contends on, keep K counters and route each writer to one of them (say, by hashing the payer), then sum the K shards when you need the total.

before: N writers → 1 account → s = 1, speedup = 1 (N serial batches)
after: N writers → K accounts → up to K disjoint writers per batch
→ serial fraction drops toward 1/K, speedup rises toward K

Nothing about Sealevel changed. You changed where the writes land, and the scheduler — which was always ready to parallelize disjoint accounts — suddenly finds K independent lanes where there was one. That is the sentence this whole part has been building to: on Solana, your data layout is your concurrency model. A global counter and a sharded counter are the same feature with wildly different throughput ceilings, and the difference is a choice you make at design time, in the account footprint.

When the ceiling meets real demand: fees go per-account

Section titled “When the ceiling meets real demand: fees go per-account”

Physics is one thing; a live network under a demand spike is another. What happens when more transactions want to write a hot account than its single serial lane can clear in a slot?

They queue, and then they fail. The account’s throughput is fixed at “one write at a time, in order,” so excess demand for that one account has nowhere to go. Historically this has not stayed politely local — the backlog around a few hot accounts has spilled into network-wide congestion and waves of failed transactions:

The engineering response is the tell. If contention is per writable account, then the way to price it must be per writable account too. Solana’s answer, rolled out gradually across roughly 2022–2024, is two connected mechanisms:

  • Priority fees. A transaction can attach an extra per-compute-unit fee to bid for earlier inclusion. It turns “retry-spam until you get in” into “state how badly you want in, and pay for it.”
  • Local fee markets. Crucially, that bidding is scoped to the specific writable accounts the transaction touches. Congestion on one hot account raises the price for writes to that account — not for the whole chain. A transaction that touches only cold, uncontended accounts pays the base fee even while a memecoin’s market account is in a bidding war right next to it.
Global fee market (naive): one hot account congested → EVERYONE pays more
Local fee markets (Solana): hot account congested → only writers of THAT account bid up
disjoint traffic keeps flowing at base fee

Read that against the schedule. The scheduler already partitioned the world by writable account — that is what a conflict-free batch is. Local fee markets price the world along the exact same seam: the unit of contention (one hot account’s serial lane) is the unit of pricing (that account’s fee market). The fee market didn’t invent a new abstraction; it put a price on the scarce resource Sealevel had already isolated for it. Because contention is per-account, the fee market is per-account. One idea, wearing two hats — a scheduler above, a market below.

Priority fees and local fee markets are the economic layer that sits directly on top of Sealevel’s contention model, so they earn the five questions.

  • Why does it exist? Because a single writable account has fixed serial throughput (the Amdahl ceiling above), and when demand for it exceeds that throughput, the shortage must be rationed by price instead of by spam-and-retry, which was congesting the whole network.
  • What problem does it solve? It localizes congestion. Bidding for a hot account’s scarce serial lane stays scoped to that account, so unrelated, disjoint traffic keeps clearing at the base fee instead of being priced out by someone else’s mint or token launch.
  • What are the trade-offs? It exposes contention as a live, per-account market: fees for a hot account can spike, and applications must now reason about where their writes land to stay cheap. The mechanism prices the scarcity; it does not remove it — a hot account is still serial.
  • When should I avoid it? You don’t avoid the fee market — it’s structural — but you design around leaning on it: if a global hot account is central to your program, you shard it (see above) rather than paying an ever-rising local fee to keep contending on one lane.
  • What breaks if I remove it? Without local fee markets, hot-account demand has only one release valve — retry-spam — which globalizes congestion and drags down transactions that never touched the hot account at all. Exactly the 2021–2024 failure mode.
  1. Feed the scheduler N transactions that all write one counter account. How many batches result, how big is each, and why does a 64-core machine give no speedup?
  2. State Amdahl’s law, define s, and explain what speedup tends to as p → ∞. Why is that limit the “honest ceiling”?
  3. Compute the serial fraction s for (a) N fully independent transfers and (b) N increments to one counter, and give the resulting speedup for each.
  4. “Just make the scheduler smarter” won’t lower the hot-account ceiling, but sharding the counter into K accounts will. Explain the difference in terms of what s actually counts.
  5. Why are Solana’s fee markets local (per writable account) rather than global? Connect the answer to what a conflict-free batch is.
Show answers
  1. N batches, each with exactly one transaction. Every increment writes the same writable account, so transaction i conflicts with all earlier ones and the layering rule pushes each into its own strictly-later batch. Batches run sequentially, so only one core works at a time — the parallel runtime degenerates into the serial one, and the other 63 cores stay idle.
  2. speedup(p) = 1 / (s + (1 - s)/p), where s is the fraction of the work that must run serially. As p → ∞, the (1 - s)/p term vanishes and speedup → 1/s. That is the honest ceiling because the serial fraction never shrinks with more cores, so 1/s is the best any amount of hardware can ever reach.
  3. (a) Independent transfers share no writable account, so nothing depends on anything: s ≈ 0, and speedup ≈ p (near-linear). (b) Every increment depends on the previous one through the shared writable counter, so all the work is serial: s = 1, and speedup = 1/(1 + 0) = 1 — no gain at any core count.
  4. Amdahl’s s counts genuine data dependencies, not scheduler cleverness — two writes to one account are a real dependency, so no scheduler can parallelize them. Sharding into K counters changes the data: writers now land on K disjoint accounts, so up to K run in parallel, dropping s toward 1/K and raising the ceiling toward K. Lowering s is application design (where writes land), not runtime tuning.
  5. Because contention is itself per writable account: a conflict-free batch is precisely a partition of transactions by the writable accounts they touch, and one hot account’s serial lane is the scarce resource. Pricing along that same seam keeps a bidding war localized to writers of that account, so disjoint traffic keeps clearing at the base fee instead of being congested by someone else’s hot account. The unit of contention is the unit of pricing.