Let DuckDB write the JSON export, and stop sorting it (~3x faster) - #12
Open
gaurav wants to merge 4 commits into
Open
Let DuckDB write the JSON export, and stop sorting it (~3x faster)#12gaurav wants to merge 4 commits into
gaurav wants to merge 4 commits into
Conversation
The export spent ~80% of its wall time serializing documents in a single Python loop while seven cores idled, behind a full ORDER BY la.pmid that had to materialize all 40.9M rows before the first one could be written — ~3 minutes of an 18-minute run, and the job's peak-memory event. Both are gone. The export is now one COPY ... (FORMAT JSON, PER_THREAD_OUTPUT), so the serialization runs in C++ across every thread, and the query has no ORDER BY (issue #8): shard membership no longer depends on scan order, since each writer thread owns a file, and nothing downstream consumes PMID order — the ingest is an ElasticSearch bulk load and validate sorts its own manifest. On a 2M-document copy of the database, same machine, same query: 112.9s -> 35.5s (17.7k -> 56.2k docs/s), with byte-identical record sets (EXCEPT in both directions over all 2M rows returns nothing). _JSON_FIELDS — output name to SQL expression, in emitted order — becomes the single definition of a document, so the spec's field names, the empty-string-not-null rule and the field order are written down once; validate imports JSON_FIELDS instead of probing _document's arity. month_to_abbrev and _year_from_medline_date stay as Python because validate still normalizes efetch's side with them, and their SQL twins are pinned to them by tests over the cases an index-based lookup gets wrong ("0", out of range, "Sept", whitespace) — a divergence there would make every normalized record read as a PubMed mismatch. Two consequences worth knowing: - --shards N is now a maximum rather than a count. One file per writer thread is what PER_THREAD_OUTPUT gives, so it caps that statement's thread count (restored afterwards) and a small dataset can use fewer. - DuckDB appends to the output directory rather than clearing it, so the export deletes its own pubmed_metadata_* files first — otherwise a shorter run leaves a previous run's shards to be read as current. Per-batch progress lines go with the Python loop; a heartbeat logs output size and current RSS once a minute in their place, which is what sizes the next --mem. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Records the 2026-08-05 full-corpus run (40,923,261 documents, 18m 06s, peak RSS 201.0 GiB) and marks all three measured runs as predating the rewrite: they are the numbers to beat, not the numbers to request. The two figures to read off the next cluster run are called out, since neither follows from a laptop benchmark — peak RSS especially, which is what decides whether --mem=256G can come down now that the sort is gone. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The Python writer said `ensure_ascii=False` out loud; DuckDB's JSON writer does the same by default, which means nothing in the repo would notice if that ever changed. One test now asserts the bytes on disk, since a consumer reading an escaped code point where it expects the accented character is exactly the kind of break a passing suite would hide. Also notes in CLAUDE.md that PARTITION_BY (pmid % shards) — the obvious way to get an exact shard count back — is rejected by DuckDB <= 1.5.4 for FORMAT JSON, so the next person doesn't spend the same twenty minutes finding out. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
gaurav
force-pushed
the
faster-json-export
branch
from
August 6, 2026 06:39
3433061 to
15045dc
Compare
NDJSON compresses ~4-5x, so a full corpus goes from ~52 GiB to roughly 12 — less to write, less for the next validate to read back, and less to keep around. Compression happens as each shard is written, so it costs CPU (which the COPY rewrite freed up) rather than a second pass. This is only a safe default because nothing downstream has to be told: the report-reading side already matched .ndjson and .ndjson.gz alike, and validate's byte-progress denominator is the compressed size either way since it reads through a raw handle. test_cli_export_then_validate_needs_no_flags runs both commands with no flags to keep that true; the validate suite's fixture is now a gzipped export for the same reason, which also exercises appending a second gzip member to a shard. --no-gzip keeps the old behaviour, and is what the CLI test now covers. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Makes the JSON export ~3x faster by deleting the two things the last run's progress log pointed at: a global sort that had to finish before a single row could be written, and a single-threaded Python serialization loop that then did all the work on one core. Closes #8.
What the log showed
From the 2026-08-05 full-corpus run (40,923,261 documents, 18m 06s):
elapsed 3m 00swith exactly 5,000 documents — onefetchmany(batch_size=5000). Nothing had been written for three minutes becauseORDER BY la.pmidmust materialize and sort all 40.9M rows first. Reproduced locally on 5M rows: first batch at 0.02s without the sort, 2.04s with it.What changed
DuckDB writes the JSON — one
COPY (...) TO <dir> (FORMAT JSON, PER_THREAD_OUTPUT true)instead of afetchmanyloop callingjson.dumpsper row, so serialization runs in C++ on every thread. The query no longer sorts.Measured end-to-end on a 2M-document copy of the database (same machine, same query,
--shards 8):ORDER BYCOPY, no sortThe two outputs are identical: 1,995,600 rows each, and
EXCEPTin both directions over the full record set returns nothing. (The new files are ~1.5% smaller — DuckDB writes compact JSON wherejson.dumpsdefaults to", "/": "separators.)Keeping the record shape honest
_JSON_FIELDS— output field name → SQL expression, in emitted order — is now the single definition of a document, and theCOPYprojection is built from it. So the DocumentMetadataAPI names, the empty-string-not-null rule and the field order are written down once.validateimportsJSON_FIELDSinstead of probing_document's arity, which deletes that hack.month_to_abbrevand_year_from_medline_datestay as Python, becausevalidatenormalizes the efetch side with them. Their SQL twins (_PUB_MONTH_SQL,_PUB_YEAR_SQL) are built from the same_MONTH_ABBR/ regex and pinned to the Python by two tests over the cases an index-based lookup gets wrong ("0","13","99999999999999999999","Sept","SEPTEMBER", whitespace,None). Both were mutation-tested: dropping the capitalization from the month key, or loosening the year regex to([0-9]{4}), fails them.Three behaviour changes to know about
--shards Nis a maximum, not a count. Output is one file per writer thread, so--shardscaps that statement's threads (restored afterwards) and a small dataset can be written by fewer. Default is DuckDB's own thread count. On Slurm, match it to--cpus-per-task.pubmed_metadata_0.ndjson.gz);--no-gzipopts out. NDJSON compresses ~4-5x, so a full corpus lands at roughly 12 GiB rather than 52 — less written by the export, less read back byvalidate, less kept around. Compression happens as each shard is written, costing CPU (which this PR just freed up) rather than a second pass. Safe only because nothing downstream needs telling:find_shardsalready matched both extensions, andcheck_structurereads through a raw handle so its byte-progress denominator is the compressed size either way.test_cli_export_then_validate_needs_no_flagsrunsexportthenvalidatewith no flags on either to keep that true.validatebuilds its own sorted PMID manifest.Also: DuckDB appends to a per-thread output directory rather than clearing it, so a shorter run would have left the previous run's shards behind to be read as current. The export now deletes its own
pubmed_metadata_*files first (tested).Progress output
Per-batch progress lines go with the Python loop. A heartbeat logs output size and current RSS once a minute in their place — no ETA, since the total output size isn't known until it's written:
(Shapes, not measurements — see below.)
-vadditionally enables DuckDB's own progress bar.Verification
/proccurrent-RSS check, which skips on macOS)._documentdid, including identifier ordering, the month abbreviation and the MedlineDate year recovery.\uXXXXescapes, and the export→validate round trip with no flags on either command.validate's fixture is now a gzipped export, so its whole suite runs against what the CLI actually writes (and exercises appending a second gzip member to a shard).Not verified
The new peak RSS and wall time on the cluster. The 3x is a laptop measurement on a 2M-document database; the full corpus is 20x that and the machine is different. Two things to record from the first real run:
--mem=256Gis probably now over-provisioned — but by how much is a measurement, and under-requesting is an OOM kill several minutes in.slurm/README.mdsays to keep 256G until a new number exists.--time=02:00:00is still generous.TODO
slurm/README.mddeliberately still says to request 256 GB until a real number exists — this is what replaces it, and it is also the first measurement of the gzipped default's CPU cost.--sample-sizemeans now that shard count is variable.validatesamples--sample-sizerecords per shard, so a 16-shard export at the default samples 240 — but with one file per writer thread, the shard count is whatever the export's parallelism happened to be, and the total sample silently moves with it. Either make the flag a total (dividing across shards) or document the coupling; leaving it is the option that quietly changes how much gets checked between runs.🤖 Generated with Claude Code