Post

When PostgreSQL JIT Costs More Than the Query: Measuring Compile Time on Short Queries

A PostgreSQL query that runs in 11 ms spent 119 ms end to end, 108 of them compiling it with LLVM JIT. A reproducible experiment on PostgreSQL 16, 17 and 18: where the cost thresholds sit, what each tier costs, and how to switch it off at the right scope.

When PostgreSQL JIT Costs More Than the Query: Measuring Compile Time on Short Queries

A list query over 10,000 rows, indexed, returning 10 rows, runs in 11 ms on PostgreSQL 18. With default settings the same query takes 119 ms, and 108 of those milliseconds are spent compiling it to machine code. The query never got slower; PostgreSQL decided it was expensive enough to be worth compiling, and the compile cost ten times what the query itself needed.

This post reproduces that effect from scratch on PostgreSQL 16, 17 and 18, measures what each JIT tier costs, shows exactly where the planner's cost thresholds switch them on, and ends with the options for turning JIT off at the narrowest scope that fixes the problem. Everything in it comes from the harness at the bottom, which needs only Docker and Python. It also covers what did not reproduce: on the analytic query I tried, JIT never paid off.

What JIT does and when the planner turns it on

PostgreSQL can compile parts of a query to native code with LLVM. The documentation names two things it accelerates: expression evaluation (WHERE clauses, target lists, aggregates) and tuple deforming. It is aimed at long-running, CPU-bound queries, usually analytical ones, and the same page warns that for short queries the overhead of compiling is often higher than the time it saves.

The decision is made from the planner's total estimated cost, at plan time, against three settings. Their defaults are the same in versions 16, 17 and 18:

settingdefaultwhat it switches on
jit_above_cost100,000JIT compilation at all
jit_inline_above_cost500,000inlining of short functions and operators
jit_optimize_above_cost500,000expensive LLVM optimization passes
flowchart TD
  P["Planner total cost for the query"] --> A{"jit on and cost above jit_above_cost (100,000)?"}
  A -- no --> N["Interpreted expressions, no compile step"]
  A -- yes --> B["Compile expressions and tuple deforming"]
  B --> C{"cost above jit_inline_above_cost (500,000)?"}
  C -- yes --> I["Also inline small functions"]
  B --> D{"cost above jit_optimize_above_cost (500,000)?"}
  D -- yes --> O["Also run expensive optimization"]

Two consequences matter below. The decision uses the planner's cost, not a time, so an inflated estimate pays for compilation the query will never earn back. And the compile happens per execution, in the backend that runs the query. In the measurements below nothing was reused between executions: the 21st run in a session paid for compilation as much as the first.

The experiment

The schema is a synthetic task tracker: tasks(id, parent_id, status, title) where about one row in ten is a parent, and task_deps(task_id, blocker_id) for "this task is blocked by that one". Seeding is deterministic (setseed plus fixed formulas), at two sizes, 10,000 and 100,000 tasks, followed by ANALYZE. Every query returns identical rows on all three versions; the harness prints a row count and checksum for Q1 to Q3 to prove it.

Four queries, all in the harness:

  • Q1, low cost. Three correlated NOT EXISTS checks over the indexed columns. Planner cost 6,755 at 10,000 rows.
  • Q2, middle. "Ready" tasks: nothing blocks the task, its parent or any of its children, written as correlated EXISTS / NOT EXISTS subqueries. Planner cost 198,986, between jit_above_cost and the 500,000 tier.
  • Q3, high. Q2 plus two more levels of nested EXISTS (siblings, and tasks that depend on this one). Planner cost 577,376, above all three thresholds, although the optimizer expects about 173 rows and the query returns 10.
  • Q4, analytic. A long aggregation over a separate 3-million-row table, with a dozen arithmetic aggregates. It is there to look for a case where JIT wins.

Each query runs under five configurations, interleaved inside one session (A, B, C, A, B, C and so on, so that background load on a busy laptop hits every configuration alike): the defaults; jit = off; the inline and optimize thresholds raised to 1,000,000,000 so only the cheap tier remains; all three thresholds raised; and all three set to 0 to force the full tier. Each cell is one discarded warm-up round plus 21 timed runs, and the tables report the median with the 25th to 75th percentile in brackets. The time is psql's \timing for the plain query with rows discarded, so it includes planning, JIT and execution as a client sees them; a second set of runs uses EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) to read the JIT block and the cost.

Environment: Docker 29 in a Linux VM (4 vCPU, 4 GB, Ubuntu 24.04, aarch64) on an Apple M2 Pro laptop, official postgres:16, postgres:17 and postgres:18 images (16.15, 17.11 and 18.6, the current minors at the time of writing) with default configuration. In all three, SELECT pg_jit_available() returned true, jit was on and jit_provider was llvmjit. These are aarch64 Debian builds, so the absolute numbers will differ on x86 or with another LLVM; the shape of the results is what carries over.

What the defaults cost

PostgreSQL 18.6, 10,000 tasks, median of 21 runs, milliseconds:

queryplanner costJIT tier at defaultsjit = offdefaultsof which JITslowdown
Q16,755none6.36.401.0x
Q2198,986compile only7.015.88.42.3x
Q3577,376compile, inline, optimize10.9119.1108.010.9x

Q3 is the one to look at. The JIT block that EXPLAIN ANALYZE prints for it:

1
2
3
4
5
6
JIT:
  Functions: 80
  Options: Inlining true, Optimization true, Expressions true, Deforming true
  Timing: Generation 1.131 ms (Deform 0.360 ms), Inlining 7.474 ms,
          Optimization 55.857 ms, Emission 56.864 ms, Total 121.327 ms
Execution Time: 133.989 ms

(A single EXPLAIN ANALYZE from a separate session, its second execution; the medians in the table come from 21 runs.) Generation is cheap. Optimization and emission, the LLVM work, are almost all of it. The Execution Time reported by EXPLAIN ANALYZE includes that JIT time, so the real work of the query was roughly the 11 ms the jit = off run shows.

The threshold effect

Same three queries, PostgreSQL 18.6, 10,000 tasks, median wall time in milliseconds:

configurationQ1 (6,755)Q2 (198,986)Q3 (577,376)
defaults6.415.8119.1
jit = off6.37.010.9
inline and optimize thresholds raised6.315.624.6
all three thresholds raised6.57.110.9
all three thresholds set to 0 (forced)128.877.4118.8

Read it row by row. Raising only the two expensive thresholds takes Q3 from 119 ms to 24.6 ms: the cheap tier still costs about 13 ms there, more than the query itself, but inlining and optimization were the bulk of the bill. Raising all three matches jit = off. The forced row shows what the full tier costs in isolation: even Q1, a 6 ms query the planner correctly left alone, takes 129 ms when forced through it. That fixed price of roughly 110 to 130 ms follows the size of the expression tree, not the number of rows the query touches, as the next table shows.

The overhead does not grow with the data

Same queries, 100,000 tasks, PostgreSQL 18.6:

queryplanner costjit = offdefaultsof which JIT
Q187,41767.370.10
Q22,995,56682.9157.877.5
Q37,339,736146.7273.7127.9

With ten times the rows the queries get slower and the planner's cost goes up, so Q2 now crosses 500,000 and gets the full tier too: its JIT bill grows from 8 ms to 78 ms. Q3's bill barely moves (108 ms, then 128 ms), so with more data it matters less in relative terms (its penalty falls from 10.9x to 1.9x), and it is still a pure loss: compiled expressions did not repay it. Note Q1 at 87,417: it sits 13 percent under jit_above_cost. That is the cliff the PostgreSQL commit message below describes, a few more rows or one more condition away from a 120 ms tax.

Same behavior on 16, 17 and 18

Q3 at the defaults against jit = off, medians in milliseconds, with the JIT time that EXPLAIN attributes:

version10,000 rows, jit = offdefaultsJIT100,000 rows, jit = offdefaultsJIT
16.1511.1132.2116.7138.5259.8123.1
17.1112.6149.2137.4160.4308.9112.4
18.610.9119.1108.0146.7273.7127.9

The planner cost for Q3 at 10,000 rows is 577,376 on all three, the thresholds are the same, and so is the outcome. The 100,000-row figures on 16 and 17 had a wide spread between runs on a busy laptop (for 16.15 defaults the 25th to 75th percentile was 256 to 549 ms), which is why the JIT column, taken from the plan, is the steadier comparison there.

A new connection pays more on its first JIT query. In the session's first run of Q3 at defaults, the wall time was 158 ms on 18.6 against a median of 119 ms, 200 ms against 149 on 17.11 and 175 against 132 on 16.15. The EXPLAIN of one such first run showed Inlining at 47.8 ms against about 7.5 ms warm. A pool that keeps connections for a long time pays that once per connection; short-lived connections pay it every time.

Why the estimate is so high

The cost is in the planner's own units, not milliseconds, and the harness lets us divide one by the other, using the jit = off times (18.6):

queryrowsplanner costactual mscost per ms
Q110,0006,7556.31,072
Q210,000198,9867.028,400
Q310,000577,37610.953,000
Q3100,0007,339,736146.750,000
Q43,000,00079,945421.6190

Queries built from correlated EXISTS / NOT EXISTS subqueries come out 26 to 50 times more expensive per millisecond than the plainer Q1, and Q3 was expected to return 173 rows and returned 10. I did not dig into the planner source to say exactly which estimates compound; what the measurement shows is the ratio. The parallel analytic query is at the other end: 190 cost units per millisecond, below the 100,000 threshold in total (a parallel plan's cost is spread across workers), so JIT never started by itself.

The PostgreSQL project reached a similar view. A commit on 2 April 2026 changed the default of jit to off, explaining that compiling and optimizing can cost more than JIT saves on shorter queries, that the choice is driven by a simple cost comparison without a reliable estimate of the LLVM cost, that small unrelated changes can push the cost just over a threshold, and that most large-scale deployments, the hyperscalers included, already disable it. The PostgreSQL 19 release notes list that change, and the 19 documentation gives off as the default; at the time of writing 19 was at release candidate 1, so 16, 17 and 18 keep on.

Where JIT did not help

Q4 was meant to be the counterexample: a long aggregation with a dozen arithmetic aggregates over 3 million rows. At the defaults its plan cost was about 79,900, below jit_above_cost, so JIT stayed off and it ran in 421 ms on 18.6, 457 ms on 16.15 and 538 ms on 17.11. Forcing JIT on with the thresholds at 0 made it slower on every version, by 12 percent on 18.6 (473 vs 422 ms), 4 percent on 16.15 (475 vs 456 ms) and, on a noisier run, 23 percent on 17.11 (673 vs 547 ms). While building the harness I also tried, ad hoc and outside it, a 5-million-row table with a dozen aggregates and a 2-million-row, 30-column table read at its last columns, and neither favored JIT in this environment either. So the honest result of this experiment is that it did not reproduce a JIT win, not that none exists: the documentation's claim about long CPU-bound analytic queries stands, and my queries were not that on this hardware and LLVM build. If JIT matters for a workload, measure that workload.

Options and their scope

All the cost settings and jit itself can be changed per session (in pg_settings their context is user on 18.6; jit_provider is postmaster). They differ in how far the change reaches:

optionscopenote
SET jit = offthe current sessiongone at reconnect, so a pool must run it on every new connection
options=-c jit=off in the connection string, or PGOPTIONS='-c jit=off'every connection made with that stringcheck with SHOW jit that a pooler in the middle passes it through
ALTER ROLE app SET jit = off, optionally IN DATABASEevery new session of that roleapplied at login; SET ROLE does not apply it
ALTER DATABASE db SET jit = offevery new session in that databaseall roles in it
jit = off in postgresql.confthe whole serveralso switches off JIT for the analytic work that benefits
raise jit_inline_above_cost and jit_optimize_above_costas abovekeeps the cheap tier; took Q3 from 119 to 24.6 ms here
raise jit_above_costas abovekeeps JIT for genuinely large plans; pick a value from your own cost distribution

A practical order: find the queries whose JIT Total in EXPLAIN (ANALYZE) is a large share of Execution Time; set jit = off for the application role that runs short transactional queries, so a reporting role or an analyst's session keeps the default; and go server-wide only once nothing on the server benefits. Settings applied at plan time also matter for prepared statements with a generic plan: the documentation says the values in effect at prepare time control the decision, not those at execution time.

Managed platforms differ in what they ship, so run SELECT pg_jit_available(); and SHOW jit; on the real database rather than assuming. The first returns true only when a JIT provider is available and jit is on, per the function reference.

What this does and does not show

  • The effect is real and large for short queries with an inflated cost estimate: ten times slower for an 11 ms query, and a fixed 80 to 130 ms tax on every execution of a query that crosses the thresholds, in these measurements.
  • It is not a claim that JIT is bad. It did not win on my analytic query, but that is one query on one aarch64 build.
  • The EXPLAIN runs add their own overhead (see the exec column in the raw output), which is why the tables use the plain-query wall time.
  • The laptop was busy and noisy. Medians and interquartile ranges are reported for that reason; the 100,000-row cells on 16 and 17 are the least steady.

Reproduce it

Needs Docker and Python 3 (standard library only). It starts one container per version, named jit-lab-16, jit-lab-17 and jit-lab-18, removes each when it finishes, and takes about twelve minutes.

1
2
python3 jit_lab.py     # prints one line per cell, writes jit_lab.json
python3 jit_report.py  # Markdown tables with percentile spreads

The harness, jit_lab.py:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
#!/usr/bin/env python3
"""Measure PostgreSQL JIT cost on short queries. Needs Docker and python3 only."""
import json, re, statistics, subprocess, sys, time

VERSIONS = ["16", "17", "18"]
SIZES = [10_000, 100_000]
RUNS = 21                      # timed runs per cell, after one discarded warm-up round

SEED = """
DROP TABLE IF EXISTS task_deps, tasks;
CREATE TABLE tasks (
  id integer PRIMARY KEY,
  parent_id integer REFERENCES tasks(id),
  status text NOT NULL,
  title text NOT NULL);
CREATE TABLE task_deps (
  task_id integer NOT NULL REFERENCES tasks(id),
  blocker_id integer NOT NULL REFERENCES tasks(id),
  PRIMARY KEY (task_id, blocker_id));
SELECT setseed(0.42);
INSERT INTO tasks
SELECT i,
       CASE WHEN i % 10 = 0 THEN NULL
            ELSE (floor(random() * (:n / 10)) + 1)::int * 10 END,
       (ARRAY['todo','doing','done','done','done'])[1 + floor(random() * 5)::int],
       'task ' || i
FROM generate_series(1, :n) i;
INSERT INTO task_deps
SELECT t.id, 1 + floor(random() * :n)::int
FROM tasks t, generate_series(1, 2) g
WHERE random() < 0.6
ON CONFLICT DO NOTHING;
DELETE FROM task_deps WHERE task_id = blocker_id;
CREATE INDEX ON tasks (parent_id);
CREATE INDEX ON tasks (status);
CREATE INDEX ON task_deps (blocker_id);
ANALYZE;
"""

BLOCKED = ("EXISTS (SELECT 1 FROM task_deps d JOIN tasks b ON b.id = d.blocker_id "
           "WHERE d.task_id = {t} AND b.status <> 'done')")

QUERIES = {
  # a few correlated subqueries, cheap plan
  "Q1 low": """SELECT t.id FROM tasks t WHERE t.status <> 'done'
 AND NOT EXISTS (SELECT 1 FROM tasks c WHERE c.parent_id = t.id AND NOT EXISTS
     (SELECT 1 FROM task_deps d JOIN tasks b ON b.id = d.blocker_id
      WHERE d.task_id = c.id AND b.status = 'done'))
 AND NOT EXISTS (SELECT 1 FROM tasks s WHERE s.parent_id = t.parent_id AND s.id <> t.id
      AND s.status = 'doing' AND NOT EXISTS
     (SELECT 1 FROM task_deps d WHERE d.task_id = s.id AND d.blocker_id = t.id))
 AND NOT EXISTS (SELECT 1 FROM task_deps d WHERE d.blocker_id = t.id AND NOT EXISTS
     (SELECT 1 FROM tasks x WHERE x.id = d.task_id AND x.status = 'todo'))""",
  # "ready" tasks: nothing blocks it, its parent, or any of its children
  "Q2 mid": """SELECT t.id FROM tasks t WHERE t.status <> 'done'
 AND NOT """ + BLOCKED.format(t="t.id") + """
 AND (t.parent_id IS NULL OR NOT """ + BLOCKED.format(t="t.parent_id") + """)
 AND NOT EXISTS (SELECT 1 FROM tasks c WHERE c.parent_id = t.id
      AND (c.status <> 'done' OR """ + BLOCKED.format(t="c.id") + "))",
  # the same plus two more levels of EXISTS
  "Q3 high": """SELECT t.id FROM tasks t WHERE t.status <> 'done'
 AND NOT """ + BLOCKED.format(t="t.id") + """
 AND (t.parent_id IS NULL OR NOT """ + BLOCKED.format(t="t.parent_id") + """)
 AND NOT EXISTS (SELECT 1 FROM tasks c WHERE c.parent_id = t.id
      AND (c.status <> 'done' OR """ + BLOCKED.format(t="c.id") + """))
 AND (t.parent_id IS NULL OR NOT EXISTS (SELECT 1 FROM tasks s
      WHERE s.parent_id = t.parent_id AND s.id <> t.id
      AND (s.status = 'doing' OR """ + BLOCKED.format(t="s.id") + """)))
 AND NOT EXISTS (SELECT 1 FROM task_deps d JOIN tasks x ON x.id = d.task_id
      WHERE d.blocker_id = t.id AND (x.status = 'doing'
      OR EXISTS (SELECT 1 FROM tasks y WHERE y.parent_id = x.id AND y.status = 'doing')))""",
  # a long analytic scan, where JIT is meant to pay off (reads its own 3M-row table)
  "Q4 analytic": """SELECT a % 4, count(*), sum(a::float8), sum(a * (1 - c / 20)),
 sum(a * (1 - c / 20) * (1 + b / 1000.0)), avg(a), avg(b), avg(c), sum(b * c),
 sum(a * b * c), sum(a * a + b * b), max(a * c - b)
FROM facts WHERE a + b > 100 GROUP BY a % 4""",
}

FACTS = """
DROP TABLE IF EXISTS facts;
CREATE TABLE facts AS
SELECT g AS id, ((g::bigint * 7919) % 1000)::int AS a, ((g::bigint * 104729) % 977)::int AS b,
       (g % 13)::float8 AS c
FROM generate_series(1, 3000000) g;
ANALYZE facts;
"""

BIG = "1000000000"
CONFIGS = {
  "defaults":    "",
  "jit=off":     "SET jit = off;",
  "tier1 only":  f"SET jit_inline_above_cost = {BIG}; SET jit_optimize_above_cost = {BIG};",
  "forced":      "SET jit_above_cost = 0; SET jit_inline_above_cost = 0; "
                 "SET jit_optimize_above_cost = 0;",
  "all raised":  f"SET jit_above_cost = {BIG}; SET jit_inline_above_cost = {BIG}; "
                 f"SET jit_optimize_above_cost = {BIG};",
}

def sh(*a, stdin=None):
    r = subprocess.run(a, input=stdin, capture_output=True, text=True)
    if r.returncode:
        sys.exit(f"{' '.join(a)}\n{r.stderr}")
    return r.stdout

def psql(c, script, *flags):
    return sh("docker", "exec", "-i", c, "psql", "-U", "postgres", "-X", "-q", "-A", "-t",
              "-v", "ON_ERROR_STOP=1", *flags, stdin=script)

def med(xs):
    return statistics.median(xs)

def wait_ready(c):
    # the official image starts a temporary server for init, then restarts: wait for the second
    for _ in range(90):
        time.sleep(1)
        log = subprocess.run(["docker", "logs", c], capture_output=True, text=True)
        if (log.stdout + log.stderr).count("ready to accept connections") >= 2:
            return
    sys.exit("server did not come up")

def run_query(c, sql, configs):
    """Time one query under every config. Runs are interleaved inside ONE session
    (config A, B, C, A, B, C, ...), so a noisy neighbour hits every config alike."""
    q = sql.strip()
    names = list(configs)
    def step(name):                          # one run of one config
        return f"RESET ALL;\n{configs[name]}\n"
    # 1) wall-clock as the client sees it: psql \timing, result rows discarded
    body = "".join(step(n) + "\\timing on\n" + q + ";\n\\timing off\n"
                   for _ in range(RUNS + 1) for n in names)
    times = [float(x) for x in re.findall(r"Time: ([\d.]+) ms", psql(c, "\\o /dev/null\n" + body))]
    # 2) the plan: EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON), the JIT block and the cost
    body = "".join(step(n) + f"EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) {q};\n\\echo @@@\n"
                   for _ in range(RUNS + 1) for n in names)
    plans = [json.loads(p)[0] for p in psql(c, body).split("@@@") if p.strip()]
    cells = {}
    for i, n in enumerate(names):
        mine = plans[i::len(names)]
        jit = [p.get("JIT", {}).get("Timing", {}) for p in mine]
        cells[n] = {
            "cost": mine[0]["Plan"]["Total Cost"],
            "jit_functions": mine[0].get("JIT", {}).get("Functions", 0),
            "jit_options": mine[0].get("JIT", {}).get("Options"),
            "cold_ms": times[i],                  # first run of this config in the session
            "wall_ms": times[len(names) + i::len(names)],
            "plan_ms": [p["Planning Time"] for p in mine[1:]],
            "exec_ms": [p["Execution Time"] for p in mine[1:]],   # includes JIT time
            "jit": {k: [t.get(k, 0.0) for t in jit[1:]]
                    for k in ("Generation", "Inlining", "Optimization", "Emission", "Total")},
        }
    return cells

def main():
    out = {}
    for v in VERSIONS:
        c = f"jit-lab-{v}"
        subprocess.run(["docker", "rm", "-f", c], capture_output=True)
        sh("docker", "run", "-d", "--name", c, "-e", "POSTGRES_PASSWORD=lab", f"postgres:{v}")
        try:
            wait_ready(c)
            info = psql(c, "SELECT version(), pg_jit_available(), "
                        "current_setting('jit'), current_setting('jit_above_cost'), "
                        "current_setting('jit_inline_above_cost'), "
                        "current_setting('jit_optimize_above_cost'), "
                        "current_setting('jit_provider');").strip().split("|")
            print(f"\n== {info[0]}\n   pg_jit_available={info[1]} jit={info[2]} above={info[3]} "
                  f"inline={info[4]} optimize={info[5]} provider={info[6]}", flush=True)
            out[v] = {"info": info, "cells": {}}
            for n in SIZES:
                psql(c, SEED, "-v", f"n={n}")
                for name, sql in QUERIES.items():
                    if name.startswith("Q4"):
                        if n != SIZES[0]:
                            continue                 # does not depend on the seeded size
                        psql(c, FACTS)
                    chk = psql(c, f"SELECT count(*), md5(string_agg(id::text, ',' ORDER BY id)) "
                               f"FROM ({sql}) s;" if not name.startswith("Q4") else "SELECT 1;")
                    cfgs = {k: v for k, v in CONFIGS.items()
                            if not name.startswith("Q4") or k != "tier1 only"}
                    for cname, cell in run_query(c, sql, cfgs).items():
                        cell["result"] = chk.strip()
                        out[v]["cells"][f"{n}|{name}|{cname}"] = cell
                        print(f"{v} n={n:<6} {name:<12} {cname:<11} cost={cell['cost']:>11.0f} "
                              f"wall={med(cell['wall_ms']):8.1f} exec={med(cell['exec_ms']):8.1f} "
                              f"jit={med(cell['jit']['Total']):7.1f} {cell['result'][:18]}", flush=True)
        finally:
            sh("docker", "rm", "-f", c)
    json.dump(out, open("jit_lab.json", "w"), indent=1)

if __name__ == "__main__":
    main()

The table generator, jit_report.py:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
#!/usr/bin/env python3
"""Turn jit_lab.json into Markdown tables: median and (p25-p75) in milliseconds."""
import json, statistics

data = json.load(open("jit_lab.json"))
m = statistics.median

def span(xs):
    q = statistics.quantiles(xs, n=4)
    return f"{m(xs):.1f} ({q[0]:.1f}-{q[2]:.1f})"

def phase(c, k):
    # PostgreSQL 17+ nests Generation as {"Deform": .., "Total": ..}
    xs = [x["Total"] if isinstance(x, dict) else x for x in c["jit"][k]]
    return m(xs)

for v, d in data.items():
    print(f"\n### {d['info'][0].split(' on ')[0]}  (pg_jit_available={d['info'][1]})\n")
    for size in sorted({k.split("|")[0] for k in d["cells"]}, key=int):
        print(f"rows={size}\n")
        print("| query | config | plan cost | wall ms | JIT total | gen | inline | optimize | emit |")
        print("|---|---|---:|---:|---:|---:|---:|---:|---:|")
        for key, c in d["cells"].items():
            n, q, cfg = key.split("|")
            if n != size:
                continue
            print(f"| {q} | {cfg} | {c['cost']:.0f} | {span(c['wall_ms'])} | {phase(c, 'Total'):.1f} | "
                  f"{phase(c, 'Generation'):.1f} | {phase(c, 'Inlining'):.1f} | "
                  f"{phase(c, 'Optimization'):.1f} | {phase(c, 'Emission'):.1f} |")
        print()

Console output of that run for PostgreSQL 18.6 (wall and exec in milliseconds, exec being the EXPLAIN ANALYZE execution time including JIT; jit the JIT total; the last field is the row count and a checksum of the result, identical on all three versions):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
PostgreSQL 18.6 (Debian 18.6-1.pgdg13+2) on aarch64-unknown-linux-gnu, compiled by gcc (Debian 14.2.0-19) 14.2.0, 64-bit
   pg_jit_available=t jit=on above=100000 inline=500000 optimize=500000 provider=llvmjit
18 n=10000  Q1 low       defaults    cost=       6755 wall=     6.4 exec=    10.2 jit=    0.0 208|759cc7c49b31ac
18 n=10000  Q1 low       jit=off     cost=       6755 wall=     6.3 exec=    10.2 jit=    0.0 208|759cc7c49b31ac
18 n=10000  Q1 low       tier1 only  cost=       6755 wall=     6.3 exec=    10.2 jit=    0.0 208|759cc7c49b31ac
18 n=10000  Q1 low       forced      cost=       6755 wall=   128.8 exec=   129.3 jit=  119.3 208|759cc7c49b31ac
18 n=10000  Q1 low       all raised  cost=       6755 wall=     6.5 exec=    10.2 jit=    0.0 208|759cc7c49b31ac
18 n=10000  Q2 mid       defaults    cost=     198986 wall=    15.8 exec=    16.9 jit=    8.4 1210|8ce8ca534f89d
18 n=10000  Q2 mid       jit=off     cost=     198986 wall=     7.0 exec=     8.2 jit=    0.0 1210|8ce8ca534f89d
18 n=10000  Q2 mid       tier1 only  cost=     198986 wall=    15.6 exec=    16.8 jit=    8.3 1210|8ce8ca534f89d
18 n=10000  Q2 mid       forced      cost=     198986 wall=    77.4 exec=    78.3 jit=   70.0 1210|8ce8ca534f89d
18 n=10000  Q2 mid       all raised  cost=     198986 wall=     7.1 exec=     8.4 jit=    0.0 1210|8ce8ca534f89d
18 n=10000  Q3 high      defaults    cost=     577376 wall=   119.1 exec=   120.1 jit=  108.0 10|a350d26dc6f818f
18 n=10000  Q3 high      jit=off     cost=     577376 wall=    10.9 exec=    12.2 jit=    0.0 10|a350d26dc6f818f
18 n=10000  Q3 high      tier1 only  cost=     577376 wall=    24.6 exec=    25.8 jit=   13.3 10|a350d26dc6f818f
18 n=10000  Q3 high      forced      cost=     577376 wall=   118.8 exec=   120.0 jit=  107.8 10|a350d26dc6f818f
18 n=10000  Q3 high      all raised  cost=     577376 wall=    10.9 exec=    12.2 jit=    0.0 10|a350d26dc6f818f
18 n=10000  Q4 analytic  defaults    cost=      79945 wall=   421.4 exec=   468.9 jit=    0.0 1
18 n=10000  Q4 analytic  jit=off     cost=      79945 wall=   421.6 exec=   470.0 jit=    0.0 1
18 n=10000  Q4 analytic  forced      cost=      79945 wall=   472.7 exec=   538.5 jit=  350.9 1
18 n=10000  Q4 analytic  all raised  cost=      79945 wall=   419.8 exec=   468.5 jit=    0.0 1
18 n=100000 Q1 low       defaults    cost=      87417 wall=    70.1 exec=   119.6 jit=    0.0 2315|94370c8ae07a3
18 n=100000 Q1 low       jit=off     cost=      87417 wall=    67.3 exec=   117.0 jit=    0.0 2315|94370c8ae07a3
18 n=100000 Q1 low       tier1 only  cost=      87417 wall=    69.1 exec=   119.6 jit=    0.0 2315|94370c8ae07a3
18 n=100000 Q1 low       forced      cost=      87417 wall=   187.3 exec=   245.5 jit=  127.2 2315|94370c8ae07a3
18 n=100000 Q1 low       all raised  cost=      87417 wall=    70.3 exec=   118.6 jit=    0.0 2315|94370c8ae07a3
18 n=100000 Q2 mid       defaults    cost=    2995566 wall=   157.8 exec=   175.2 jit=   77.5 12115|677fa3f4ca56
18 n=100000 Q2 mid       jit=off     cost=    2995566 wall=    82.9 exec=   101.6 jit=    0.0 12115|677fa3f4ca56
18 n=100000 Q2 mid       tier1 only  cost=    2995566 wall=    96.4 exec=   111.9 jit=    9.5 12115|677fa3f4ca56
18 n=100000 Q2 mid       forced      cost=    2995566 wall=   158.7 exec=   178.1 jit=   79.3 12115|677fa3f4ca56
18 n=100000 Q2 mid       all raised  cost=    2995566 wall=    81.9 exec=   100.8 jit=    0.0 12115|677fa3f4ca56
18 n=100000 Q3 high      defaults    cost=    7339736 wall=   273.7 exec=   294.9 jit=  127.9 75|d7003c8d8731289
18 n=100000 Q3 high      jit=off     cost=    7339736 wall=   146.7 exec=   170.0 jit=    0.0 75|d7003c8d8731289
18 n=100000 Q3 high      tier1 only  cost=    7339736 wall=   168.3 exec=   187.5 jit=   16.2 75|d7003c8d8731289
18 n=100000 Q3 high      forced      cost=    7339736 wall=   273.9 exec=   294.9 jit=  128.8 75|d7003c8d8731289
18 n=100000 Q3 high      all raised  cost=    7339736 wall=   139.5 exec=   168.9 jit=    0.0 75|d7003c8d8731289

Sources

This post is licensed under CC BY 4.0 by the author.