Skip to main content

bestool_postgres/
pgtune.rs

1//! PostgreSQL tuning value computation.
2//!
3//! Produces the settings a PostgreSQL server on a Tamanu host should run with,
4//! replicating pgtune's values for an OLTP workload on SSD storage, adjusted for
5//! a resource budget that reserves headroom for the co-located application,
6//! reverse proxy, and backup tooling.
7//!
8//! The same budget and expected values back the doctor's tuning health check, so
9//! a host tuned from here passes that check.
10
11pub mod conf_block;
12
13/// One kibibyte's worth of the KiB unit these functions work in (i.e. 1).
14const KIB: u64 = 1;
15/// A mebibyte expressed in KiB.
16const MIB: u64 = 1024 * KIB;
17/// A gibibyte expressed in KiB.
18const GIB: u64 = 1024 * MIB;
19
20/// The host operating system, which changes a handful of tuning values.
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum Platform {
23	Linux,
24	Windows,
25}
26
27impl Platform {
28	/// The platform this build targets.
29	pub const fn current() -> Self {
30		if cfg!(target_os = "windows") {
31			Self::Windows
32		} else {
33			Self::Linux
34		}
35	}
36
37	fn is_windows(self) -> bool {
38		matches!(self, Self::Windows)
39	}
40}
41
42/// Raw host resources, before any budgeting.
43#[derive(Debug, Clone, Copy)]
44pub struct HostResources {
45	/// Total physical memory, in kibibytes.
46	pub total_ram_kib: u64,
47	/// Number of logical CPUs.
48	pub cpus: u32,
49}
50
51/// The budgeted resources every tuning value is derived from.
52#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53pub struct Budget {
54	/// Memory, in kibibytes, that PostgreSQL may size its allocated caches
55	/// against (shared_buffers, work_mem, and the rest).
56	pub ram_kib: u64,
57	/// Memory, in kibibytes, the operating system and PostgreSQL together have
58	/// for caching data, used to size `effective_cache_size`. Unlike `ram_kib`
59	/// this is not subject to the Windows shared-memory ceiling, because it is a
60	/// planner hint about the whole box rather than memory PostgreSQL allocates.
61	pub cache_ram_kib: u64,
62	/// CPUs PostgreSQL may spread parallel work across.
63	pub cpus: u32,
64}
65
66/// Everything needed to compute a full tuning set.
67#[derive(Debug, Clone, Copy)]
68pub struct TuneInputs {
69	pub platform: Platform,
70	pub resources: HostResources,
71	/// The server's major version (e.g. 16).
72	pub pg_major: u32,
73	/// The `max_connections` to size around.
74	pub max_connections: u32,
75	/// Whether the running server reports lz4 as a supported WAL compression
76	/// method. When unknown, pass `false`.
77	pub lz4_wal_supported: bool,
78	/// The `temp_file_limit`, in kibibytes, when it could be derived from the
79	/// data volume. Emitted only when present.
80	pub temp_file_limit_kib: Option<u64>,
81}
82
83/// A single `key = value` tuning directive.
84#[derive(Debug, Clone, PartialEq, Eq)]
85pub struct Setting {
86	pub key: &'static str,
87	pub value: String,
88}
89
90impl Setting {
91	fn new(key: &'static str, value: impl Into<String>) -> Self {
92		Self {
93			key,
94			value: value.into(),
95		}
96	}
97}
98
99/// The RAM PostgreSQL is budgeted, in kibibytes.
100///
101/// Windows hosts are treated as entitled to at most 4 GiB, because they
102/// co-locate more workloads and PostgreSQL for Windows does not benefit from a
103/// large shared memory area. Elsewhere the budget is the whole-box cache budget.
104fn budget_ram_kib(platform: Platform, total_ram_kib: u64) -> u64 {
105	if platform.is_windows() {
106		(total_ram_kib / 2).min(4 * GIB)
107	} else {
108		cache_budget_ram_kib(total_ram_kib)
109	}
110}
111
112/// The RAM available for caching data across the whole box, in kibibytes, used
113/// to size `effective_cache_size`. It holds back a fixed 4 GiB of headroom on
114/// hosts with real RAM and splits small hosts down the middle, on every
115/// platform: the Windows shared-memory ceiling does not apply, because this
116/// figure is a planner hint about the operating system and PostgreSQL together,
117/// not memory PostgreSQL allocates.
118fn cache_budget_ram_kib(total_ram_kib: u64) -> u64 {
119	if total_ram_kib >= 8 * GIB {
120		total_ram_kib - 4 * GIB
121	} else {
122		total_ram_kib / 2
123	}
124}
125
126/// The CPUs PostgreSQL is budgeted, reserving cores for co-located workloads.
127fn budget_cpus(cpus: u32) -> u32 {
128	if cpus >= 8 {
129		cpus - 4
130	} else if cpus >= 5 {
131		4
132	} else if cpus >= 2 {
133		2
134	} else {
135		1
136	}
137}
138
139/// Compute the resource budget for a host.
140pub fn budget(platform: Platform, resources: HostResources) -> Budget {
141	Budget {
142		ram_kib: budget_ram_kib(platform, resources.total_ram_kib),
143		cache_ram_kib: cache_budget_ram_kib(resources.total_ram_kib),
144		cpus: budget_cpus(resources.cpus),
145	}
146}
147
148/// Expected `shared_buffers`, in kibibytes, for a budget.
149pub fn expected_shared_buffers_kib(budget: &Budget, platform: Platform, pg_major: u32) -> u64 {
150	let value = budget.ram_kib / 4;
151	if platform.is_windows() && pg_major < 10 {
152		value.min(512 * MIB)
153	} else {
154		value
155	}
156}
157
158/// Expected `effective_cache_size`, in kibibytes, for a budget. Sized from the
159/// whole-box cache budget, so it reflects the host's real RAM rather than the
160/// capped memory budget Windows allocates against.
161pub fn expected_effective_cache_kib(budget: &Budget) -> u64 {
162	(budget.cache_ram_kib * 3) / 4
163}
164
165/// Expected `maintenance_work_mem`, in kibibytes, for a budget.
166pub fn expected_maintenance_kib(budget: &Budget, platform: Platform, pg_major: u32) -> u64 {
167	let value = budget.ram_kib / 16;
168	let limit = if platform.is_windows() && pg_major <= 17 {
169		2 * GIB
170	} else {
171		8 * GIB
172	};
173	if value >= limit {
174		if platform.is_windows() && pg_major <= 17 {
175			limit - MIB
176		} else {
177			limit
178		}
179	} else {
180		value
181	}
182}
183
184fn expected_work_mem_kib(
185	budget: &Budget,
186	platform: Platform,
187	pg_major: u32,
188	max_connections: u32,
189) -> u64 {
190	let shared = expected_shared_buffers_kib(budget, platform, pg_major);
191	let parallel = if budget.cpus >= 4 { budget.cpus } else { 8 };
192	let divisor = u64::from(max_connections + parallel) * 3;
193	let mut value = (budget.ram_kib.saturating_sub(shared)) / divisor.max(1);
194	value = value.max(4 * MIB);
195	if platform.is_windows() && pg_major <= 17 {
196		value = value.min(2 * GIB - MIB);
197	}
198	value
199}
200
201fn expected_wal_buffers_kib(shared_buffers_kib: u64) -> u64 {
202	let raw = (shared_buffers_kib * 3) / 100;
203	let capped = raw.clamp(32, 16 * MIB);
204	// pgtune snaps a value within the top band up to the full 16 MiB.
205	if capped > 14 * MIB && capped < 16 * MIB {
206		16 * MIB
207	} else {
208		capped
209	}
210}
211
212/// Render a kibibyte quantity with the largest unit that divides it exactly,
213/// matching the `kB`/`MB`/`GB` forms PostgreSQL accepts.
214fn render_kib(kib: u64) -> String {
215	if kib.is_multiple_of(GIB) {
216		format!("{}GB", kib / GIB)
217	} else if kib.is_multiple_of(MIB) {
218		format!("{}MB", kib / MIB)
219	} else {
220		format!("{kib}kB")
221	}
222}
223
224/// Compute the full tuning set, in canonical order.
225pub fn compute(input: &TuneInputs) -> Vec<Setting> {
226	let TuneInputs {
227		platform,
228		resources,
229		pg_major,
230		max_connections,
231		lz4_wal_supported,
232		temp_file_limit_kib,
233	} = *input;
234	let budget = budget(platform, resources);
235	let mut out = Vec::new();
236
237	let shared_buffers = expected_shared_buffers_kib(&budget, platform, pg_major);
238	out.push(Setting::new("max_connections", max_connections.to_string()));
239	out.push(Setting::new("shared_buffers", render_kib(shared_buffers)));
240	out.push(Setting::new(
241		"effective_cache_size",
242		render_kib(expected_effective_cache_kib(&budget)),
243	));
244	out.push(Setting::new(
245		"maintenance_work_mem",
246		render_kib(expected_maintenance_kib(&budget, platform, pg_major)),
247	));
248	out.push(Setting::new("checkpoint_completion_target", "0.9"));
249	out.push(Setting::new(
250		"wal_buffers",
251		render_kib(expected_wal_buffers_kib(shared_buffers)),
252	));
253	out.push(Setting::new("default_statistics_target", "100"));
254	out.push(Setting::new("random_page_cost", "1.1"));
255
256	// effective_io_concurrency relies on posix_fadvise, absent on Windows.
257	if !platform.is_windows() {
258		out.push(Setting::new("effective_io_concurrency", "200"));
259	}
260
261	out.push(Setting::new(
262		"work_mem",
263		render_kib(expected_work_mem_kib(
264			&budget,
265			platform,
266			pg_major,
267			max_connections,
268		)),
269	));
270	out.push(Setting::new(
271		"huge_pages",
272		if shared_buffers >= 2 * GIB {
273			"try"
274		} else {
275			"off"
276		},
277	));
278	out.push(Setting::new("min_wal_size", "2GB"));
279	out.push(Setting::new("max_wal_size", "8GB"));
280
281	if budget.cpus >= 4 {
282		let per_gather = budget.cpus.div_ceil(2).min(4);
283		out.push(Setting::new(
284			"max_worker_processes",
285			budget.cpus.to_string(),
286		));
287		out.push(Setting::new(
288			"max_parallel_workers_per_gather",
289			per_gather.to_string(),
290		));
291		if pg_major >= 10 {
292			out.push(Setting::new(
293				"max_parallel_workers",
294				budget.cpus.to_string(),
295			));
296		}
297		if pg_major >= 11 {
298			out.push(Setting::new(
299				"max_parallel_maintenance_workers",
300				per_gather.to_string(),
301			));
302		}
303	}
304
305	let autovacuum_max_workers = if budget.cpus >= 32 {
306		Some(5)
307	} else if budget.cpus >= 16 {
308		Some(4)
309	} else {
310		None
311	};
312	if let Some(workers) = autovacuum_max_workers {
313		out.push(Setting::new("autovacuum_max_workers", workers.to_string()));
314	}
315	if expected_maintenance_kib(&budget, platform, pg_major) >= 2 * GIB {
316		out.push(Setting::new("autovacuum_work_mem", "2GB"));
317	}
318
319	if pg_major >= 18 {
320		let io_workers = (budget.cpus / 4).clamp(3, 32);
321		if io_workers > 3 {
322			out.push(Setting::new("io_workers", io_workers.to_string()));
323		}
324		// io_uring is Linux-only; leave the default worker method on Windows.
325		if !platform.is_windows() {
326			out.push(Setting::new("io_method", "io_uring"));
327		}
328	}
329
330	if pg_major >= 12 {
331		out.push(Setting::new("jit", "off"));
332	}
333
334	let wal_compression = if pg_major >= 15 {
335		if lz4_wal_supported {
336			Some("lz4")
337		} else {
338			Some("on")
339		}
340	} else if pg_major >= 10 {
341		Some("on")
342	} else {
343		None
344	};
345	if let Some(value) = wal_compression {
346		out.push(Setting::new("wal_compression", value));
347	}
348
349	if let Some(kib) = temp_file_limit_kib {
350		out.push(Setting::new("temp_file_limit", render_kib(kib)));
351	}
352
353	out
354}
355
356#[cfg(test)]
357mod tests {
358	use super::*;
359
360	fn get<'a>(settings: &'a [Setting], key: &str) -> Option<&'a str> {
361		settings
362			.iter()
363			.find(|s| s.key == key)
364			.map(|s| s.value.as_str())
365	}
366
367	fn windows_inputs(total_gib: u64, cpus: u32) -> TuneInputs {
368		TuneInputs {
369			platform: Platform::Windows,
370			resources: HostResources {
371				total_ram_kib: total_gib * GIB,
372				cpus,
373			},
374			pg_major: 16,
375			max_connections: 100,
376			lz4_wal_supported: false,
377			temp_file_limit_kib: None,
378		}
379	}
380
381	#[test]
382	fn windows_budget_is_capped_at_4gib() {
383		assert_eq!(budget_ram_kib(Platform::Windows, 4 * GIB), 2 * GIB);
384		assert_eq!(budget_ram_kib(Platform::Windows, 8 * GIB), 4 * GIB);
385		assert_eq!(budget_ram_kib(Platform::Windows, 16 * GIB), 4 * GIB);
386		assert_eq!(budget_ram_kib(Platform::Windows, 64 * GIB), 4 * GIB);
387	}
388
389	#[test]
390	fn linux_budget_reserves_four_gib() {
391		assert_eq!(budget_ram_kib(Platform::Linux, 32 * GIB), 28 * GIB);
392		assert_eq!(budget_ram_kib(Platform::Linux, 4 * GIB), 2 * GIB);
393	}
394
395	#[test]
396	fn cache_budget_ignores_windows_ceiling() {
397		// The cache budget reserves the same 4 GiB headroom but is never capped,
398		// so effective_cache_size tracks the whole box on Windows too.
399		assert_eq!(cache_budget_ram_kib(4 * GIB), 2 * GIB);
400		assert_eq!(cache_budget_ram_kib(8 * GIB), 4 * GIB);
401		assert_eq!(cache_budget_ram_kib(16 * GIB), 12 * GIB);
402		assert_eq!(cache_budget_ram_kib(64 * GIB), 60 * GIB);
403	}
404
405	#[test]
406	fn windows_effective_cache_tracks_whole_box() {
407		// 16 GiB Windows box: memory budget stays 4 GiB, but effective_cache_size
408		// is three quarters of the 12 GiB cache budget.
409		assert_eq!(
410			get(&compute(&windows_inputs(16, 4)), "effective_cache_size"),
411			Some("9GB")
412		);
413		assert_eq!(
414			get(&compute(&windows_inputs(64, 16)), "effective_cache_size"),
415			Some("45GB")
416		);
417	}
418
419	#[test]
420	fn windows_shared_buffers_scale_with_budget() {
421		assert_eq!(
422			get(&compute(&windows_inputs(4, 2)), "shared_buffers"),
423			Some("512MB")
424		);
425		assert_eq!(
426			get(&compute(&windows_inputs(8, 2)), "shared_buffers"),
427			Some("1GB")
428		);
429		assert_eq!(
430			get(&compute(&windows_inputs(16, 4)), "shared_buffers"),
431			Some("1GB")
432		);
433		assert_eq!(
434			get(&compute(&windows_inputs(64, 16)), "shared_buffers"),
435			Some("1GB")
436		);
437	}
438
439	#[test]
440	fn windows_omits_linux_only_settings() {
441		let s = compute(&windows_inputs(16, 8));
442		assert_eq!(get(&s, "effective_io_concurrency"), None);
443		assert_eq!(get(&s, "io_method"), None);
444	}
445
446	#[test]
447	fn windows_16gib_core_values() {
448		let s = compute(&windows_inputs(16, 4));
449		// memory budget = 4GiB, cache budget = 12GiB
450		assert_eq!(get(&s, "effective_cache_size"), Some("9GB"));
451		assert_eq!(get(&s, "maintenance_work_mem"), Some("256MB"));
452		assert_eq!(get(&s, "max_wal_size"), Some("8GB"));
453		assert_eq!(get(&s, "random_page_cost"), Some("1.1"));
454		assert_eq!(get(&s, "huge_pages"), Some("off"));
455	}
456
457	#[test]
458	fn parallelism_gated_on_cpu_budget() {
459		// 2 CPUs -> budget 2 -> no parallel settings.
460		let s = compute(&windows_inputs(16, 2));
461		assert_eq!(get(&s, "max_worker_processes"), None);
462		// 8 CPUs -> budget 4 -> parallel settings present.
463		let s = compute(&windows_inputs(16, 8));
464		assert_eq!(get(&s, "max_worker_processes"), Some("4"));
465		assert_eq!(get(&s, "max_parallel_workers_per_gather"), Some("2"));
466		assert_eq!(get(&s, "max_parallel_workers"), Some("4"));
467		assert_eq!(get(&s, "max_parallel_maintenance_workers"), Some("2"));
468	}
469
470	#[test]
471	fn wal_compression_falls_back_without_lz4() {
472		let mut input = windows_inputs(16, 4);
473		input.pg_major = 16;
474		input.lz4_wal_supported = false;
475		assert_eq!(get(&compute(&input), "wal_compression"), Some("on"));
476		input.lz4_wal_supported = true;
477		assert_eq!(get(&compute(&input), "wal_compression"), Some("lz4"));
478		input.pg_major = 13;
479		input.lz4_wal_supported = true;
480		assert_eq!(get(&compute(&input), "wal_compression"), Some("on"));
481	}
482
483	#[test]
484	fn pre_pg10_windows_caps_shared_buffers() {
485		let mut input = windows_inputs(64, 8);
486		input.pg_major = 9;
487		// budget 4GiB -> /4 = 1GiB, capped to 512MiB pre-10.
488		assert_eq!(get(&compute(&input), "shared_buffers"), Some("512MB"));
489		// no jit / wal_compression pre-10/12
490		assert_eq!(get(&compute(&input), "jit"), None);
491		assert_eq!(get(&compute(&input), "wal_compression"), None);
492	}
493
494	#[test]
495	fn temp_file_limit_emitted_when_present() {
496		let mut input = windows_inputs(16, 4);
497		input.temp_file_limit_kib = Some(50 * GIB);
498		assert_eq!(get(&compute(&input), "temp_file_limit"), Some("50GB"));
499		input.temp_file_limit_kib = None;
500		assert_eq!(get(&compute(&input), "temp_file_limit"), None);
501	}
502
503	#[test]
504	fn linux_32gib_matches_ops_tuning() {
505		let input = TuneInputs {
506			platform: Platform::Linux,
507			resources: HostResources {
508				total_ram_kib: 32 * GIB,
509				cpus: 8,
510			},
511			pg_major: 16,
512			max_connections: 100,
513			lz4_wal_supported: true,
514			temp_file_limit_kib: None,
515		};
516		let s = compute(&input);
517		// budget = 28GiB
518		assert_eq!(get(&s, "shared_buffers"), Some("7GB"));
519		assert_eq!(get(&s, "effective_cache_size"), Some("21GB"));
520		assert_eq!(get(&s, "random_page_cost"), Some("1.1"));
521		assert_eq!(get(&s, "effective_io_concurrency"), Some("200"));
522		assert_eq!(get(&s, "max_wal_size"), Some("8GB"));
523		assert_eq!(get(&s, "wal_compression"), Some("lz4"));
524	}
525
526	#[test]
527	fn render_kib_uses_largest_exact_unit() {
528		assert_eq!(render_kib(GIB), "1GB");
529		assert_eq!(render_kib(512 * MIB), "512MB");
530		assert_eq!(render_kib(32), "32kB");
531		assert_eq!(render_kib(1536 * MIB), "1536MB");
532	}
533}