Skein · RyanAI LLC · performance report
The largest migration we have run, measured end to end on one laptop against a live PostgreSQL server. Every figure below came off a real run and can be reproduced from a command in the repository. The failures we found on the way are in here too, because a report with none is not a report.
A 2,124 MB database became a 900,000 node graph in 911 MB, holding 800,000 connections. Seventeen source rows became one node, and the rows themselves never moved.
Averages hide the only thing worth seeing here, so this is per table.
| Table | Treatment | Rows | Seconds | Share of the time |
|---|---|---|---|---|
| contacts | one node per row | 400,000 | 84.4 | |
| customers | one node per row | 100,000 | 41.1 | |
| projects | one node per row | 200,000 | 8.4 | |
| events | summarised, two hops out | 10,000,000 | 2 | |
| invoices | summarised per customer | 5,000,000 | under 1 | |
| Total | 15,700,000 | 136 |
Read the bottom two rows twice. Fifteen million of the fifteen point seven million rows cost three seconds between them, because they are aggregated inside PostgreSQL and never cross the wire. The entire import is paid for by the 700,000 rows that become nodes, and by nothing else.
Without that, the same database would have produced roughly 15.7 million nodes: the shape of import nobody runs twice, and the reason so many graph projects are abandoned after the first load.
Speed on wrong numbers is worthless, so the totals were checked against the source rather than trusted.
Fernwood Studio 11359 · nothing open · last activity today
FIGURES ON RECORD $63,000.00 invoices for Fernwood Studio 11359
RECENT invoices for Fernwood Studio 11359
events for Fernwood Studio 11359
That total is computed over every one of that customer's invoices, and not one of those invoice rows was copied into the graph.
A performance report is a document full of claims, and this one is no different. The live corpus holds 155,388 records from five separate businesses. Search it, open any of the twenty-five subjects, read the brief and look at the graph. Every timing on that page is measured on your own machine as you use it, not quoted from ours.
| Operation | Median | 95th percentile |
|---|---|---|
| Brief on one subject | 0.4 ms | 0.6 ms |
| Answer a counting question | 1.6 ms | 1.6 ms |
| Search the whole graph | 63 ms | 77 ms |
A brief costs what the subject's own neighbourhood costs, not what the graph costs. A hundred thousand customers with six connections each is cheap; twenty clients with fifty thousand each is not. The number to watch is the degree of the busiest entity, never the node count.
Summarised tables are nearly free, so the ceiling is set by rows that become nodes. Four sizes, one process, nothing else on the machine.
| Entity rows | Nodes | Rows/sec | Peak memory | Wall clock | On disk |
|---|---|---|---|---|---|
| 250,000 | 650,000 | 5,618 | 190 MB | 1.5 min | 608 MB |
| 1,000,000 | 1,400,000 | 5,144 | 192 MB | 4.0 min | 1,320 MB |
| 2,000,000 | 2,400,000 | 4,144 | 194 MB | 8.8 min | 2,170 MB |
| 4,000,000 | 4,400,000 | 3,989 | 194 MB | 17.4 min | 3,881 MB |
Memory is flat. 190 MB at a quarter of a million rows and 194 MB at four million. What the importer holds is bounded by our design rather than by the customer's table.
Throughput falls 8.2% per doubling of the graph, which is database index depth increasing. That is a slope, not a wall: sixteen times the rows costs 1.4 times the seconds per row. From those measured constants, ten million entities is roughly 45 minutes. That last sentence is arithmetic, not a measurement, and is labelled as such wherever we use it.
The figure not to extrapolate is disk. A graph runs about 900 bytes a node, so forty million nodes is 39 GB. Check that against the machine before quoting anybody.
Three defects found by measuring rather than by reasoning. Each one had been shipping, green, and invisible.
Every imported node carries source_ref, the table and key it
came from. The importer looks a node up by it once per summary, so a 15.7M row
import does 200,000 of them. There was no index, so each was a full scan.
51.7 ms per lookup, 105 minutes of scanning. The index builds in 0.2 seconds and takes the same lookup under a millisecond. It had gone missing because it was created inside the migration that added the column, and once the column joined the schema proper that branch never fired again.
Both database drivers buffer the entire result set the moment the query returns, so reading in batches of 2,000 was slicing a list that was already fully in memory. Measured at 4,000,000 rows, in separate processes: 657 MB against 43 MB, and the query blocked for 1.11 seconds before returning a single row.
A hundred million row table could not have been read at all, on any hardware, which is exactly the size this design exists to serve.
The events table pointed at invoices, invoices were themselves summarised, so there was nothing in the graph to count events against and they were skipped without an error. A summary now follows one more hop, so events are counted per customer. Two hops and no further: past that the join gets expensive and the answer stops meaning anything a person would recognise.
Every one was found by measuring something we had already claimed. The alternative to publishing them is a report where nothing ever went wrong, which is not a report anybody in this market believes, because they have all been handed one before.
docs/PERFORMANCE.md. Where
a number is projected rather than measured, it says so on the same line.Skein by RyanAI LLC · Missouri · getskein.io. Prepared for evaluation. No customer data appears anywhere in this report: the source is a generated database and the names shown are synthetic.