Skip to content

Optimize eager loads - #1473

Merged
stephenafamo merged 4 commits into
aarondl:masterfrom
nicowolf91:optimize-eager-loads
Jun 8, 2026
Merged

Optimize eager loads#1473
stephenafamo merged 4 commits into
aarondl:masterfrom
nicowolf91:optimize-eager-loads

Conversation

@nicowolf91

Copy link
Copy Markdown
Contributor

Currently, eager loading of relationships uses nested loops, which results in O(n^2) behavior.
For large datasets, this can significantly hurt performance.

I replaced the nested-loop matching with map-based lookups.
After the results are bound, a lookup map is populated from the relevant relationship key values.
Primitive values are normalized with fmt.Sprintf, while nullable values, especially null.XXX structs, are converted through the database/sql/driver.Valuer interface.

This ensures that nullable foreign key fields are matched correctly while preserving the existing eager-loading behavior.

Please let my know if anything is missing!

@stephenafamo

Copy link
Copy Markdown
Collaborator

The thing is, maps are slower for a smaller set.

The ideal way would be to decide map vs loop based on the size of the result set

@stephenafamo

Copy link
Copy Markdown
Collaborator

Also, it would be nice to include a micro benchmark so we can judge the tradeoff

@nicowolf91

Copy link
Copy Markdown
Contributor Author

Thanks for your quick reply!

I put together a quick benchmark:

runtime_compare_loop_map_line_log
goos: darwin
goarch: arm64
cpu: Apple M4 Pro
BenchmarkMainLoadPartsEager
BenchmarkMainLoadPartsEager/1mains-10parts-loop
BenchmarkMainLoadPartsEager/1mains-10parts-loop-14         	    4069	    290397 ns/op
BenchmarkMainLoadPartsEager/1mains-10parts-map
BenchmarkMainLoadPartsEager/1mains-10parts-map-14          	    4662	    290097 ns/op
BenchmarkMainLoadPartsEager/10mains-100parts-loop
BenchmarkMainLoadPartsEager/10mains-100parts-loop-14       	    2895	    401584 ns/op
BenchmarkMainLoadPartsEager/10mains-100parts-map
BenchmarkMainLoadPartsEager/10mains-100parts-map-14        	    2965	    429085 ns/op
BenchmarkMainLoadPartsEager/100mains-1_000parts-loop
BenchmarkMainLoadPartsEager/100mains-1_000parts-loop-14    	     931	   1285239 ns/op
BenchmarkMainLoadPartsEager/100mains-1_000parts-map
BenchmarkMainLoadPartsEager/100mains-1_000parts-map-14     	     872	   1318857 ns/op
BenchmarkMainLoadPartsEager/200mains-2_000parts-loop
BenchmarkMainLoadPartsEager/200mains-2_000parts-loop-14    	     525	   2300341 ns/op
BenchmarkMainLoadPartsEager/200mains-2_000parts-map
BenchmarkMainLoadPartsEager/200mains-2_000parts-map-14     	     434	   2429133 ns/op
BenchmarkMainLoadPartsEager/300mains-3_000parts-loop
BenchmarkMainLoadPartsEager/300mains-3_000parts-loop-14    	     306	   3604048 ns/op
BenchmarkMainLoadPartsEager/300mains-3_000parts-map
BenchmarkMainLoadPartsEager/300mains-3_000parts-map-14     	     368	   3349516 ns/op
BenchmarkMainLoadPartsEager/500mains-5_000parts-loop
BenchmarkMainLoadPartsEager/500mains-5_000parts-loop-14    	     192	   6044010 ns/op
BenchmarkMainLoadPartsEager/500mains-5_000parts-map
BenchmarkMainLoadPartsEager/500mains-5_000parts-map-14     	     241	   4941213 ns/op
BenchmarkMainLoadPartsEager/1_000mains-10_000parts-loop
BenchmarkMainLoadPartsEager/1_000mains-10_000parts-loop-14 	      78	  14377310 ns/op
BenchmarkMainLoadPartsEager/1_000mains-10_000parts-map
BenchmarkMainLoadPartsEager/1_000mains-10_000parts-map-14  	     132	   8980813 ns/op
BenchmarkMainLoadPartsEager/3_000mains-30_000parts-loop
BenchmarkMainLoadPartsEager/3_000mains-30_000parts-loop-14 	      12	  97993674 ns/op
BenchmarkMainLoadPartsEager/3_000mains-30_000parts-map
BenchmarkMainLoadPartsEager/3_000mains-30_000parts-map-14  	      46	  24892293 ns/op
BenchmarkMainLoadPartsEager/10_000mains-100_000parts-loop
BenchmarkMainLoadPartsEager/10_000mains-100_000parts-loop-14         	       2	 851870875 ns/op
BenchmarkMainLoadPartsEager/10_000mains-100_000parts-map
BenchmarkMainLoadPartsEager/10_000mains-100_000parts-map-14          	      12	  84196000 ns/op
BenchmarkMainLoadPartsEager/20_000mains-200_000parts-loop
BenchmarkMainLoadPartsEager/20_000mains-200_000parts-loop-14         	       1	3195858083 ns/op
BenchmarkMainLoadPartsEager/20_000mains-200_000parts-map
BenchmarkMainLoadPartsEager/20_000mains-200_000parts-map-14          	       7	 164980202 ns/op
PASS

Currently, the benchmark uses models that only have primitives as ID columns.
Our production setup uses nullable foreign keys which means that the generated code uses sqlboiler's queries.Equal to compare matching entities, which seems to get quite costly for large datasets.
I used pprof to trace the CPU usage for slow eager loads with a sample of my real-world database:

Showing top 20 nodes out of 165
      flat  flat%   sum%        cum   cum%
    3680ms  9.28%  9.28%    14930ms 37.64%  runtime.mallocgcSmallScanNoHeader
    3550ms  8.95% 18.23%    12300ms 31.01%  github.com/aarondl/sqlboiler/v4/queries.Equal
    2390ms  6.03% 24.26%     2720ms  6.86%  runtime.(*mspan).writeHeapBitsSmall
    2360ms  5.95% 30.21%     2640ms  6.66%  runtime.findObject
    2330ms  5.87% 36.08%     2330ms  5.87%  runtime.nextFreeFast (inline)
    1770ms  4.46% 40.54%    12560ms 31.67%  sqlboiler/assemblyStepL.LoadDescriptionTranslationTranslation
    1630ms  4.11% 44.65%    16970ms 42.79%  runtime.mallocgc
    1520ms  3.83% 48.49%     1520ms  3.83%  runtime.memequal
    1320ms  3.33% 51.82%    12370ms 31.19%  sqlboiler/assemblyStepL.LoadNameTranslationTranslation
    1240ms  3.13% 54.94%     5810ms 14.65%  runtime.scanobject
    1220ms  3.08% 58.02%     1220ms  3.08%  runtime.markBits.setMarked (inline)
    1090ms  2.75% 60.77%     1090ms  2.75%  runtime.getMCache (inline)
     840ms  2.12% 62.88%      840ms  2.12%  runtime.memclrNoHeapPointers
     800ms  2.02% 64.90%      800ms  2.02%  reflect.toType (inline)
     700ms  1.77% 66.67%      710ms  1.79%  runtime.(*mspan).heapBitsSmallForAddr
     610ms  1.54% 68.20%    11840ms 29.85%  runtime.convTstring
     580ms  1.46% 69.67%      620ms  1.56%  runtime.(*gcBits).bitp (inline)
     490ms  1.24% 70.90%      490ms  1.24%  runtime.memmove
     490ms  1.24% 72.14%     5150ms 12.99%  sqlboiler/assemblyStepL.LoadDescriptionHTMLTranslationTranslation
     400ms  1.01% 73.15%      400ms  1.01%  runtime.acquirem (inline)

The map approach should also remove the need for queries.Equal (reflection).

@stephenafamo
stephenafamo merged commit 9c1bf19 into aarondl:master Jun 8, 2026
2 checks passed
mguentner added a commit to mguentner/sqlboiler_many_to_many_bug that referenced this pull request Jul 11, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants