Andrey Minogin

Reliability and Performance of LLM, JVM and Postgres systems

Tag: Profiler

  • One character at a time: a 7× regression in DHIS2

    One character at a time: a 7× regression in DHIS2

    From time to time I voluntarily do performance audits of open source projects. DHIS2 is a health information system used by ministries of health in about seventy countries. It ships a bulk data export API, dataValueSets, that integrations lean on heavily — data warehouses, donor reporting, tracker-to-aggregate sync. I put it on a bench, on the public demo database, and started measuring.

    Although the 2.43 release promises major import/export gains, on the same machine, the same database and the same request, exporting dataValueSets.json got 7.3× slower than in 2.42.

    24-month export2.42.5.22.43.1
    JSON3.25 s23.79 s
    CSV3.77 s7.39 s
    JSON + gzip4.29 s51.17 s

    Three things are worth noticing before any profiler is opened:

    • CSV got 3.6 s slower, JSON 20.5 s — same rows, same database;
    • PostgreSQL time is always 2.91 s (pg_stat_statements);
    • Compression — which puts 31× fewer bytes on the wire — makes the request 27 seconds slower!

    Every step below is the same move: change one thing and measure again. Format. Compression. Time window. Version. Environment stays untouched. This is how we get a lot of ideas without even looking at the source code.

    The control column

    The bench was deliberately boring: the public Sierra Leone demo database, PostgreSQL 16, fixed container limits, curl to /dev/null, cold run discarded. Anyone can rerun it. Both versions returned exactly 973,730 values for the same window.

    I measured formats × compression × three time windows on both versions. CSV went in for completeness. It turned out to be the most valuable column in the table.

    Spread across those values, the cost of producing one of them:

    cost per data value2.422.43
    JSON3.3 µs24.4 µs
    CSV3.9 µs7.6 µs

    JSON went up 7×, CSV under 2× — same rows, same database. That single column kills most of the diagnostic tree before you touch a tool: we exclude the database, the network, the container, the JVM flags. Whatever happened, happened between the ResultSet and the socket, on the JSON side only. Two immediate thoughts — either the JSON response is simply heavier or the serializer was somehow broken.

    The gzip anomaly

    Let’s check the response path: if we send ?compression=gzip, does the finding go away?

    It didn’t go away. It got 27 seconds worse.

    First conclusion: it’s definitely not the network. You can’t blame transfer costs when removing 97% of the transfer makes things worse.

    Second, and more useful: it’s not just the serializer. Per MB of input, compressing that JSON costs 0.094–0.108 s; compressing the CSV from the same runs costs 0.004 s. Same algorithm, same level, same library — a 25× asymmetry per input byte. gzip does not care what it compresses. There should be some difference in the way we stream the result.

    Again — useful ideas without even touching the source.

    Where the time goes

    The JFR recording is unambiguous: 60% of samples in JsonAppender, 8% in JDBC and Hibernate combined. The hot frame:

    Java
    PrintStream.write(String)
    
      ← PrintStream.print(char)
    
      ← PrintStream.append(char)
    
      ← JsonAppender.appendEscaped(int) line: 123
    
      ← IntPipeline$Head.forEachOrdered

    2.43 replaced the Jackson writer with json-tree 1.8.1, whose appender writes through a PrintStream. PrintStream.append(char) is synchronized and flushes on every call — once per character, roughly 250 million times per export. That’s the gzip number too: the deflater wasn’t handed a stream, it was handed a doorbell.

    Checked, not argued: a DHIS2 build with a later json-tree — same host, same script, same 973,730 values — brings the gzip penalty from +27.4 s down to +0.5 s.

    About the assistant in the room

    Most of the grinding was done by Claude Code. It does well, but I don’t see how it could operate without human steering. It generates far more branches than I would, and most are wrong in recognisable ways: narrowing to whatever is easiest to measure, inventing parameters nobody asked for, merging two findings because the combination looks stronger, promoting an observation to a result.

    So most of my work was refusing branches, one message each. No, we’re not benchmarking index usage — first say what would even count as a problem here. No, no isolated microbenchmark of the library — pull the branch and rerun the whole thing. To kill nine branches out of ten you have to know which one is load-bearing, and the tool can’t know that for you.

    The generation still earned its place: the gzip run is a step I might have skipped alone, and it turned me around at least twice — once by pointing out that a neighbouring path still runs on Jackson and doesn’t have the problem at all.

    What made this assistant-proof: the objection is almost always one measurement away. “It’s just JSON being slow” — measure CSV. “It’s the network” — turn on gzip. “It’s already fixed upstream” — build that branch and measure it. Minutes each, and each replaces an argument with a number.

    What I did about it

    Reported before publishing: json-tree #102 for the per-character escaping, json-tree #103 for a correctness bug found on the way (case -31 in the escape switch is unreachable, so control characters below 0x20 are emitted raw — still present in 1.9.4), and a Community of Practice thread asking for the json-tree bump to be backported.

    The same endpoint carries a second, unrelated regression, reported in its own thread: a default ORDER BY that spills to disk before emitting a row, plus a lost JDBC fetch size — together they push time to first byte from 0.16 s to 3.3 s on both formats.

    External accounts can’t create issues in DHIS2’s Jira, so the forum is the front door.

    The flush fix exists upstream; 2.43 still pins the old json-tree, and deployments will keep installing that release for about a year. Three weeks later: no replies.

    That’s the same phenomenon the article is about. A 7× regression arrived unnoticed and was half-fixed unnoticed, in software that ministries of health run on. Nothing in the pipeline measures this, so nothing in the pipeline can notice.

    If you’re on 2.43 today: use dataValueSets.csv with ?compression=gzip — 7.94 s and 6.1 MiB against JSON’s 23.79 s and 246.4 MiB. Compression is a query parameter; Accept-Encoding does nothing. A workaround, not advice: on 2.42, JSON was the faster format.

    If you’re on anything else: put a neighbouring path in your benchmark matrix even when you don’t know why. Keep the previous version measurable on the same hardware. And when someone hands you an objection, price it — usually it’s one measurement away.

  • The flame graph was wrong by 57×. So I’m building a profiler.

    The flame graph was wrong by 57×. So I’m building a profiler.

    This is the first post about it — why we would ever need a new profiler, and where the existing tools fall short. 

    Short version: on Apache Calcite’s planner, a flame graph put a rule at 0.81% when it was actually eating 46% of the time — and ranked it sixth. 

    The code that has no hot spot

    Some systems have a hot method. You profile them, one frame is red, you fix it, you go home.

    Then there are systems where the same twenty operations run billions of times, each one taking tens of nanoseconds — a map lookup, a filter check, one edge of a graph traversal. Nothing is hot. Everything is warm. The flame graph is a smooth carpet of HashMap.putVal and ArrayList.indexOfRange, and every one of those attributions is correct and none of them is anything you can act on. Nobody is going to fix HashMap.

    The question you actually have is not “which method is hot”. It is “which of my domain operations is eating the time” — expanding a frontier, matching a rule, evaluating a predicate.

    A stack profiler cannot answer that, and the reason is more interesting than it first looks.

    The receiver is missing from the stack

    A stack frame records which code ran: declaring class, method, line. It does not record what the code ran on. The receiver is sitting right there in this, but no stack walker dereferences it — walking a stack has to stay cheap and, in a signal handler, safe.

    Usually that doesn’t matter, because the method name is a good enough proxy for what happened. It stops being good enough the moment your operations are objects dispatched through shared code — rules in a planner, handlers in a router, operators in an interpreter, message types in a broker. Then the identity you care about is exactly the part that isn’t recorded.

    Here is the sharpest example I have, and it’s from real code I didn’t write: Apache Calcite’s query planner.

    Kotlin
    // simplified — Calcite's ConverterRule
    // almost no rule overrides onMatch
    public void onMatch(RelOptRuleCall call) {
        // each rule implements only this: build one node and return
        RelNode converted = convert(rel);
    
        // the base class does the rest: register the node,
        // which re-fires every rule against it
        call.transformTo(converted);
    }

    About twenty distinct optimization rules inherit that one onMatch and don’t override it. They are twenty different objects going through one method body. In my profile, ConverterRule.onMatch holds 49% of planning time — and there is nothing in the recording that can split it, because the difference between those twenty rules lives entirely in a field the profiler never reads.

    And that makes the flame graph 57 times wrong

    “Fine,” you say, “but convert is implemented per rule, so the name is one frame deeper. Just read it.”

    It is there. And it is a trap. convert builds one node and returns; the expensive part — transformTo and the re-registration cascade under it — runs after it returns, under the base class’s frame. The subclass frame encloses the cheap half of its own firing.

    Measured: EnumerableMergeJoinRule appears in 0.81% of samples. Its actual share of planning time is 46%. The flame graph is wrong by a factor of 57, and it ranks that rule sixth while putting a 30% rule first. Not “failed to answer” — answered confidently, in the wrong order, and the reader goes and optimises the wrong thing.

    The nastiest part: in the same recording, rules that do override onMatch are attributed perfectly — 30.16% from the stacks against 30.21% from my instrument. So one profile is exact for one rule and off by 57× for another, and nothing in it tells you which case you’re in. The difference lives inside a base class you’ve never read.

    (How I know the 46% is the real number is the subject of a later article in this series.)

    46% of the time, 275× when removed

    When the profiler doesn’t answer, everyone falls back to the same move: switch the thing off, run it again, compare. It is the only technique that answers the question you actually have — what happens if I don’t do this.

    On Calcite, removing that heavy 46% EnumerableMergeJoinRule took planning from 17.8 s to 64.7 ms. 275×.

    Which is a spectacular result and not the cost of the rule. That rule shapes the search space, so the run without it explored a much smaller one — the fast run wasn’t the same problem minus one part, it was an easier problem. I had seen this before: on a graph traversal I worked on for a year, switching a piece of logic off made the traversed graph itself smaller, and every measurement I took that way was contaminated the same way.

    (This doesn’t mean Calcite should drop merge join — my workload just had nothing sorted for it to exploit.)

    So there are two different numbers here: where the time went, and what you’d actually get back by not spending it. A profiler only ever prints the first one. Here they were two orders of magnitude apart — because the rule wasn’t just spending its own 46%, it was making work for every other rule too.

    What I’m building

    A profiler that attributes time to your operations — the ones you named — instead of to the methods they happen to run through.

    The mechanism is one integer: each thread holds the id of the operation it is currently inside, and a separate thread reads every slot once a millisecond and counts.

    Kotlin
    val mergeJoin = register("mergeJoin")   // once, at startup
    
    op(mergeJoin) { rule.onMatch(call) }    // at the call site

    The id comes from the object, not the stack — so twenty rules sharing one method body produce twenty different numbers. And because the share of samples landing on an id is that operation’s share of time, an operation four orders of magnitude shorter than the sampling interval is no problem at all: precision comes from the number of samples, not the resolution of a clock. You don’t measure a nanosecond operation, you count how often you catch threads inside it.

    The boundary costs about 1.7 ns, which is why you can wrap a 200 ns operation in one. System.nanoTime() costs ten times that.

    None of this is new. Go has had it in the runtime for years as pprof labels. The JVM has no equivalent you can simply pick up — what exists is either part of a commercial APM or a low-level API with the rest left to you.

    What’s next in the series

    • The bench. A profiler cannot validate itself. Building something to check it against caught three of my own mistakes, including a comparison that said the instrumented run was 15% faster than the uninstrumented one.
    • The Calcite trial in full — including 12 points of discrepancy I still can’t account for.

    Further out: the same sampler takes every thread in one pass, which makes one tick a snapshot of how many threads were actually working. A flame graph cannot answer that — it sums across threads by construction. That’s the part I’m most curious about.