You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
AddPictureFromBytes becomes O(n²) when inserting many distinct images — a recurring "recompute via full scan" pattern across countDrawings/addMedia/countMedia/relsReader/addRels #2393
Inserting N distinct images into a single sheet via AddPictureFromBytes in a loop scales roughly O(n²), not O(n). There are (at least) four separate places on this call path that do a linear sync.Map.Range() scan over state that grows with n, on every single call:
countDrawings() — called unconditionally at the top of AddPictureFromBytes (drawingID := f.countDrawings() + 1) to compute a candidate drawingID, but that value is discarded and recomputed from the existing relationship whenever the worksheet already has a drawing (ws.Drawing != nil branch inside prepareDrawing()) — i.e. on every call after the first picture on a given sheet. Confirmed present, unchanged, in v2.9.1 through v2.11.0 (latest).
addMedia()'s duplicate-image check (f.Pkg.Range + bytes.Equal against every stored media) — a hash-indexed approach for this (noted in Saving Duplicate Images #359) turns out to fix the bulk-insert case well.
countMedia(), called from addMedia(), re-scans all of f.Pkg just to compute the next numeric media filename.
the relsReader(drawingRels) relationship-dedup scan inside AddPictureFromBytes itself.
+ additionally skip the redundant countDrawings() call
~8.3s (~4.7–4.9x total)
Per-call latency also confirms superlinear growth in the unpatched version — e.g. call #1 ≈ 26µs vs call #25,000 ≈ 2.7ms (~100x), consistent with O(n) cost per call rather than O(1).
Results expected: near-linear total scaling; each AddPictureFromBytes call shouldn't need to re-scan state proportional to how many pictures/media/drawings already exist.
Fix: I have a local patch (hash-bucket index for media dedup, an atomic counter replacing countMedia()'s scan, an indexed lookup replacing the relationship-dedup scan, and skipping the countDrawings() call when the worksheet already has a drawing) that produces the ~8.3s result above, verified for correctness (byte-identical round-trip for first/last images, no dedup false-positives between distinct images). Happy to open a PR if useful.
A fifth, related instance in addRels()
While profiling the patched version, addRels() (excelize.go) became the new dominant cost — it does a full scan on every call to compute the next unused relationship ID, instead of maintaining a running counter. This one is called from 23 sites across 12 files (chart.go, picture.go, rows.go, drawing.go, pivotTable.go, sheet.go, excelize.go, shape.go, slicer.go, stream.go, table.go, vml.go), so fixing it would speed up bulk operations across charts, pivot tables, slicers, tables, comments, and hyperlinks too — not just image embedding. Happy to help draft a patch for this one as well if there's interest.
This looks like a broader pattern, not five isolated bugs
Across countDrawings(), addMedia(), countMedia(), the relsReader dedup loop, and addRels(), the same shape keeps recurring: a derived value (a count, a max ID, a dedup check) is recomputed via a full linear scan on every call instead of being maintained incrementally. A structural fix would add a maintained counter/index to the shared types these all scan — most importantly xlsxRelationships in xmlWorkbook.go (for addRels()'s max-ID computation), plus equivalent bookkeeping for Pkg/Drawings. That would resolve not just the five call sites identified here but likely prevent the same class of issue anywhere else in the codebase that touches these shared structures during bulk operations.
Excelize version: v2.9.1 (confirmed the same code paths unchanged through v2.11.0, latest as of writing)
Description
Inserting N distinct images into a single sheet via
AddPictureFromBytesin a loop scales roughly O(n²), not O(n). There are (at least) four separate places on this call path that do a linearsync.Map.Range()scan over state that grows with n, on every single call:countDrawings()— called unconditionally at the top ofAddPictureFromBytes(drawingID := f.countDrawings() + 1) to compute a candidatedrawingID, but that value is discarded and recomputed from the existing relationship whenever the worksheet already has a drawing (ws.Drawing != nilbranch insideprepareDrawing()) — i.e. on every call after the first picture on a given sheet. Confirmed present, unchanged, in v2.9.1 through v2.11.0 (latest).addMedia()'s duplicate-image check (f.Pkg.Range+bytes.Equalagainst every stored media) — a hash-indexed approach for this (noted in Saving Duplicate Images #359) turns out to fix the bulk-insert case well.countMedia(), called fromaddMedia(), re-scans all off.Pkgjust to compute the next numeric media filename.relsReader(drawingRels)relationship-dedup scan insideAddPictureFromBytesitself.Steps to reproduce
Results observed (n=25,000, Apple M-series, embed loop only — image generation excluded):
countDrawings()callPer-call latency also confirms superlinear growth in the unpatched version — e.g. call #1 ≈ 26µs vs call #25,000 ≈ 2.7ms (~100x), consistent with O(n) cost per call rather than O(1).
Results expected: near-linear total scaling; each
AddPictureFromBytescall shouldn't need to re-scan state proportional to how many pictures/media/drawings already exist.Fix: I have a local patch (hash-bucket index for media dedup, an atomic counter replacing
countMedia()'s scan, an indexed lookup replacing the relationship-dedup scan, and skipping thecountDrawings()call when the worksheet already has a drawing) that produces the ~8.3s result above, verified for correctness (byte-identical round-trip for first/last images, no dedup false-positives between distinct images). Happy to open a PR if useful.A fifth, related instance in
addRels()While profiling the patched version,
addRels()(excelize.go) became the new dominant cost — it does a full scan on every call to compute the next unused relationship ID, instead of maintaining a running counter. This one is called from 23 sites across 12 files (chart.go, picture.go, rows.go, drawing.go, pivotTable.go, sheet.go, excelize.go, shape.go, slicer.go, stream.go, table.go, vml.go), so fixing it would speed up bulk operations across charts, pivot tables, slicers, tables, comments, and hyperlinks too — not just image embedding. Happy to help draft a patch for this one as well if there's interest.This looks like a broader pattern, not five isolated bugs
Across
countDrawings(),addMedia(),countMedia(), therelsReaderdedup loop, andaddRels(), the same shape keeps recurring: a derived value (a count, a max ID, a dedup check) is recomputed via a full linear scan on every call instead of being maintained incrementally. A structural fix would add a maintained counter/index to the shared types these all scan — most importantlyxlsxRelationshipsinxmlWorkbook.go(foraddRels()'s max-ID computation), plus equivalent bookkeeping forPkg/Drawings. That would resolve not just the five call sites identified here but likely prevent the same class of issue anywhere else in the codebase that touches these shared structures during bulk operations.Excelize version: v2.9.1 (confirmed the same code paths unchanged through v2.11.0, latest as of writing)