fangorn/ex_git_objectstore
public
perf audit: measured inefficiency in upload-pack, receive-pack and the Filesystem backend (round 1) #77
Links
No links yet.
Measured performance audit of ex_git_objectstore, companion to Anvil #372. Same method: measure first, root-cause in code, rank fixes by how deep in the stack the cost lives.
Ranking used throughout (standing protocol):
- per-unit cost in a shared primitive
- algorithmic class of a named operation (cite the published bound)
- avoid redundant work
- cache results — avoid
- schedule around it — last resort
Harness
Everything below is reproducible. Nothing was run against production; production is read-only.
- Packed corpus —
fangorn/hephaestus.git(6 packs, 1.07 GB, 31,449 objects; 21,844 reachable frommain), mounted read-only as an egosRepovia a symlink under aFilesystemstorage root. - Loose corpus — a bare clone of
fangorn/anvilwith every pack exploded viagit unpack-objects: 12,766 loose objects, 61 MB. This models the layout production actually has (236,756 loose objects, 4 packs across 39 repos). - Single-pack corpus — the same repo after
git repack -adq. Models the post-#324 world. - Reference implementation —
git rev-list --objects <ref> | git pack-objects --stdout --revson the identical object set, timed with/usr/bin/time -l. This is the bound to beat, not “faster than before”. - Sample profiler: 5 ms interval,
backtrace_depth100, one line per sample carrying:erlang.memory()alongside the stack. Call counts via:erlang.trace_pattern(mfa, true, [:call_count]).
Headline: a clone is 12–33× slower than git and holds 6.7× the memory
Same 21,844 objects, same repo, same machine:
| wall | peak memory | pack produced | |
|---|---|---|---|
git pack-objects (default: reuse on) |
1.25 s | 272 MB RSS | 195 MB |
git pack-objects --no-reuse-object --no-reuse-delta |
13.8 s | 606 MB RSS | 195 MB |
UploadPackV2 streaming fetch |
18.2 s | 1,819 MB BEAM total | 316 MB |
On the single-pack corpus (12,460 objects) the gap is wider, because git can use its bitmap and reuse the pack wholesale: git 0.06 s / 10.3 MB vs egos 1.96 s / 22.4 MB — 33× slower, 2.2× larger output.
Where the 18.2 s goes, by sample (2,998 samples):
:zlib.append_iolist/2 1763 58.8% deflate, writing the output pack
ExGitObjectstore.Pack.Reader.safe_inflate_all/5 530 17.7% inflate, reading the source packs
------
76.5% zlib
String.Unicode.upcase/3 144 4.8%
ExGitObjectstore.Pack.Delta.apply_instructions/3 86 2.9%
:crypto.hash_update/4 52 1.7%
76.5% of a clone is decompressing bytes out of a packfile and immediately recompressing them into another packfile.
F1 — Every File.* in the Filesystem backend goes through the singleton file_server_2; read throughput collapses under concurrency
Rank 1 — per-unit cost in a shared primitive.
Storage.Filesystem makes 38 File.* calls. File.read/1 is :file.read_file/1, which is a gen_server:call to the single file_server_2 process. Every loose-object read in the system funnels through one Erlang process.
storage/filesystem.ex:43:
def get_object(config, prefix, sha) do
path = object_path(config, prefix, sha)
case File.read(path) do
Measured, 6,000 loose object files, N concurrent reader processes:
| concurrency | File.read/1 |
:prim_file.read_file/1 |
ratio |
|---|---|---|---|
| 1 | 37.8 µs/read | 35.3 µs/read | 1.07× |
| 2 | 32.6 µs/read | 20.3 µs/read | 1.61× |
| 4 | 35.2 µs/read | 15.0 µs/read | 2.35× |
| 8 | 107.0 µs/read | 10.6 µs/read | 10.11× |
File.read gets worse as concurrency rises (37.8 → 107.0 µs) because the requests queue behind one process. :prim_file.read_file gets better (35.3 → 10.6 µs) because it runs on dirty IO schedulers and the reads overlap.
The profile of a clone against the loose corpus shows this directly — 475 of 617 samples (77% of wall time) are:
Storage.Filesystem.get_object/3 ; :file.call/2 ; :gen_server.call/3 ; :gen.do_call/4
Fix: :prim_file.read_file/1 for get_object, and raw-mode :file / :prim_file for the rest. The pattern is already established in the same module — get_pack_range/5 at storage/filesystem.ex:156 correctly uses :file.open(path, [:read, :binary, :raw]).
Honest scope. Substituting :prim_file.read_file in a full clone gave only 5,435 → 4,690 ms at concurrency 4 (−14%), because a clone is zlib-bound, not read-bound (see F3). The 10× is a read-throughput number, so it pays off on the read-heavy concurrent paths — LiveView diff/log/tree browsing, CI checkouts, ls-refs — rather than on clones. It is still rank 1: it is a per-unit cost in a primitive every consumer sits on, and it is a mechanical fix.
Adjacent: object_path/3 → safe_path/2 (storage/filesystem.ex:467) calls Path.expand/1 twice per object read — once on the joined path, once on the root. Measured 49,842 Path.expand/1 calls for a 12,460-object clone; safe_path + Path.join costs 10.9 µs per object. Path.expand(root) is loop-invariant.
F2 — upload-pack reads, decompresses and delta-resolves every object exactly twice
Rank 3 — avoid redundant work.
collect_objects/4 (protocol/upload_pack_v2.ex:914) walks reachability and materializes {type, content, sha} for every object including blobs. stream_generated_pack/7 then calls drop_content/1 (:762) — which exists specifically to throw that content away —
Enum.reduce(objects, {[], 0}, fn {type, _content, sha}, {acc, n} ->
{[{type, sha} | acc], n + 1}
end)
— and stream_object_contents/2 (:775) reads every object from storage a second time.
Measured call counts, one clone:
| packed corpus (21,844 objects) | loose corpus (12,460 objects) | |
|---|---|---|
ObjectResolver.read/2 |
43,689 (2.00× per object) | 24,921 (2.00× per object) |
Storage.Filesystem.get_object/3 |
— | 24,921 |
Pack.Index.lookup_in_raw/2 |
244,664 | — |
Pack.Delta.apply/2 |
158,832 | — |
Tree.encode_content/1 |
22,196 | 13,508 |
Path.expand/1 |
1,302 | 49,842 |
The fix is not to hold the content — that is what causes the memory peak. It is to keep the content the walk already had to decode for commits and trees (small, and needed for traversal anyway) and mark blobs nil so they are read once, lazily, at pack-write time. I implemented this as a measured experiment, together with F5:
| before | after | change | |
|---|---|---|---|
packed: ObjectResolver.read/2 |
43,689 | 21,845 | −50% (exactly 1× per object) |
packed: Index.lookup_in_raw/2 |
244,664 | 122,335 | −50% |
packed: Delta.apply/2 |
158,832 | 79,416 | −50% |
packed: Tree.encode_content/1 |
22,196 | 11,098 | −50% |
packed: String.upcase/1 |
385,732 | 0 | −100% |
| packed: wall | 18,034 ms | 15,112–16,559 ms | −11% |
| packed: peak BEAM total | 1,819 MB | 1,317 MB | −502 MB (−28%) |
loose: get_object/3 |
24,921 | 12,461 | −50% |
loose: Path.expand/1 |
49,842 | 24,922 | −50% |
| loose: wall | 4,627 ms | 3,894 ms | −16% |
mix test test/ex_git_objectstore/protocol/ — 112 passed with the patch applied.
This also halves the work in Index.lookup_in_raw and Delta.apply, which are downstream of the read. Both were doing 11.2× and 7.3× per object respectively; the residual multiplier is the per-pack linear scan in find_in_packs/3 (6 packs → ~5.6 index probes per read) and delta chain depth (~3.7 applications per read).
F3 — No pack-data reuse, no delta reuse, no deltification at all
Rank 2 — algorithmic class.
pack/writer.ex:26 states it plainly:
Objects are stored without delta compression (full objects only).
Every object served is inflated out of its source pack and deflated again from scratch into the output pack, and the output is fully undeltified.
The cost is not a guess — git measures it for us on the identical object set:
git pack-objectswith reuse: 1.25 sgit pack-objects --no-reuse-object --no-reuse-delta: 13.8 s
Reuse is worth 11× in git’s own implementation. egos is at 18.2 s, i.e. slower than git’s deliberately-pessimised mode, while emitting a 62% larger pack (316 MB vs 195 MB) because it never deltifies. On the single-pack corpus the ratio is 33× (0.06 s vs 1.96 s) and 2.2× on output size.
Output size is a production cost in its own right: every clone and fetch pushes 1.6–2.2× the bytes it needs to, out of a 2-vCPU droplet.
The mechanism to copy is builtin/pack-objects.c — check_object() marks an object reusable when it lives in a pack in a form the output can take verbatim, and write_reuse_object() copies the compressed bytes without ever calling inflate. Deltas are reused by re-emitting REF_DELTA when the base is also in the output set.
Staging:
- Verbatim non-delta reuse. Object is a non-delta entry in a source pack → copy its compressed bytes straight into the output. Removes both the inflate and the deflate. This alone should take the bulk of the 76.5% zlib share.
- Delta reuse. Source entry is
OFS_DELTA/REF_DELTAand its base is in the output set → re-emit asREF_DELTAwith the same compressed delta bytes. Closes the output-size gap. - Delta search for objects with no packed form (the loose case). Bigger project; relates to #46 (bitmaps) and #47 (midx).
Note the ordering interaction: on the loose layout production has today, (1) and (2) have nothing to reuse. They become the dominant win the moment server-side repack (Anvil PR #228 / #324) lands, and F7 below is what stops the repo from going loose again.
F4 — The whole-pack reuse fast path costs a full reachability walk and essentially never fires
Rank 3 — avoid redundant work.
attempt_pack_reuse/2 (protocol/upload_pack_v2.ex:651):
with {:ok, pack_sha} <- single_pack(repo),
{:ok, pack_shas} <- pack_sha_set(repo, pack_sha),
{:ok, reachable} <- reachable_sha_set(repo, wants),
true <- MapSet.equal?(reachable, pack_shas) do
reachable_sha_set/2 is a full commit+tree walk. When MapSet.equal? returns false the entire walk is discarded and collect_objects/4 immediately re-walks the same graph.
Measured on the single-pack corpus, freshly git repack -ad’d — the most favourable case that exists:
reuse MISSED. ObjectResolver.read/2 = 19,957 for a 12,460-object clone
(12,460 real walk + 7,497 discarded probe walk)
Tree.encode_content/1 = 13,508 (6,754 × 2 — the probe encodes every tree too)
It misses because the pack holds objects reachable from all refs while wants is one branch tip. Any repo with more than one branch fails the equality test permanently. Adding one unreachable loose object (simulating a push landing after a repack) changes nothing — it was already missing.
MapSet.equal? is the wrong predicate. Completeness needs MapSet.subset?(reachable, pack_shas); the “don’t leak unreachable objects” property that motivated equality is satisfied properly by per-object reuse (F3), not by whole-pack reuse. Failing that, the probe should be compared against the reachable set of all refs, computed once.
F5 — Tree.encode_content/1 Unicode-upcases every SHA it re-encodes
Rank 1 — per-unit cost in a shared primitive.
object/tree.ex:100:
{:ok, sha_bin} = Base.decode16(String.upcase(entry.sha))
SHAs on a Tree struct always come from parse_content/1 (object/tree.ex:126), which produces them via Base.encode16(sha_bin, case: :lower). They are lowercase hex by construction. String.upcase/1 dispatches to String.Unicode.upcase/3, a per-codepoint table walk over 40 characters, to produce a string that Base.decode16(sha, case: :mixed) would have accepted unchanged.
- 385,732
String.upcase/1calls per hephaestus clone - 144 of 2,964 profile samples = 4.9% of total clone CPU, split across both encode sites (73 in the walk, 65 in the pack writer)
- Removing it alone: 18,034 → 17,455–17,582 ms (−3%)
The deeper redundancy: parse_content/1 hex-encodes all 20-byte SHAs into strings, then encode_content/1 hex-decodes them back and rebuilds the identical binary the object was read from. Retaining the raw content on the Tree struct removes the round trip entirely for the common read-then-serve path.
F6 — ls-refs reads every ref in the repo before applying ref-prefix
Rank 2 — work scaled to the whole dataset when only a slice was asked for.
collect_ref_entries/2 (protocol/upload_pack_v2.ex:294) unconditionally lists and reads all of refs/heads/ and refs/tags/. filter_by_prefixes/2 (:327) filters afterwards.
Measured, synthetic repo with 5,001 refs:
ls-refs ref-prefix=refs/heads/main -> 209.1 ms for a 65-byte response
ls-refs (no prefix, all 5,001 refs) -> 214.2 ms for a 304,005-byte response
The client asked for one ref and paid for five thousand. Every git fetch sends ref-prefix args, so this is on the hot path of the most common operation.
Compounding with F1: list_files_recursive/2 (storage/filesystem.ex:558) does a File.lstat per directory entry (list_entry/2, :574) and then read_loose_ref/3 does a File.read per ref — two file_server_2 round trips per ref, 10,002 of them for the 65-byte answer above.
Fix: push the prefix list down into Ref.list/2 and the storage backend so only matching subtrees are walked.
F7 — receive-pack explodes every pushed packfile into loose objects
Rank 2 — algorithmic class. This is the origin of the production layout that makes F1 and F3 hurt.
store_pack_objects/2 → store_single_entry/2 (protocol/receive_pack.ex:513) does, per object: inflate (in the reader), zlib_compress(raw) — recompress — then put_object, which is atomic_write/2 (storage/filesystem.ex:479) = File.mkdir_p! + File.write (temp) + File.rename, i.e. three file_server_2 round trips.
Measured, 12,766 objects, 24.9 MB of already-compressed data:
put_object × 12,766 (explode to loose) = 3,557 ms (278.6 µs/object)
put_pack × 1 (same bytes) = 60 ms
------
59× faster
That excludes the per-object recompression, which the pack path does not need at all.
The comment at receive_pack.ex:504 references a real push of ~134k objects. At the measured rate that is ~37 s of file writes alone, and it is what leaves production at 236,756 loose objects with 4 packs — which is what makes git-upload-pack on fangorn/hephaestus take 18–70 s.
git’s behaviour is receive.unpackLimit (default 100): above the threshold, receive-pack runs index-pack and keeps the received pack as a pack. Doing the same removes the deflate and three file operations per object from the push path, and keeps repos in the layout F3’s reuse needs.
F8 — The per-process whole-pack cache holds every pack file for the lifetime of the request
Extends #76 with numbers from the fetch path. Rank 5 as a standalone change — the real fix is F3.
Memory trace of one hephaestus clone (:erlang.memory/0 sampled every 5 ms):
t=0.0s 64 MB total / 0 MB binary
t=1.2s 1,421 MB total / 1,352 MB binary <- collect phase; packs loaded
t=3.7s 1,145 MB total / 1,075 MB binary
... ~1,145 MB total / ~1,075 MB binary <- flat for the remaining 14 s
peak 1,819 MB total / 1,673 MB binary
ObjectResolver escalates each pack to the whole-pack path after 64 reads (object_resolver.ex:70) and cached_pack_data/2 (:300) File.reads the entire .pack into the process dictionary. 4 get_pack calls loaded 1.07 GB — the repo’s whole pack set — and it stayed resident for the full 18 s.
Attribution measured by raising @escalate_threshold to disable escalation:
| wall | peak BEAM total | |
|---|---|---|
| escalation on (current) | 18.0 s | 1,819 MB |
| escalation off (ranged reads only) | 30.0 s | 765 MB |
So today the cache trades 1.05 GB of resident memory for 12 s of wall time. Production is a 3 GB droplet: two concurrent clones of hephaestus do not fit. Neither branch of that trade is acceptable, which is the argument for F3 — verbatim reuse means streaming the pack rather than decoding it, and the reason to hold it disappears.
Measured and REJECTED
Negative results, so nobody re-runs them.
:prim_file.read_file/1vsFile.read/1single-threaded — no difference. 12,000 loose objects, warm page cache: 34.2 µs/obj vs 33.6 µs/obj (1.02×). The entire F1 effect is a concurrency effect. A single-request benchmark of this change concludes “no bug”, which is the trap.- Quadratic list handling in
collect_commit_objects/6— real in the code, not measurable.objs ++ accatupload_pack_v2.ex:966concatenates the parent’s entire ancestry list, andtree_objects ++ Enum.reverse(parent_objects)reverses it again per level. On the 1,907-commit corpus it accounted for 16 of 2,998 samples (0.5%) as a leaf, and the deepest recursion observed was 43 nested frames (under the 100-frame sampling cap). Not worth restructuring for its own sake. Walk.log/3pagination is correct.max_count: 30against hephaestus: 31ObjectResolver.read/2calls, 9 ms. No full-history walk, no whole-pack load. Hazard worth a separate one-line fix: unknown option keys are silently ignored, so a caller typo (limit: 30instead ofmax_count: 30) walks all 1,907 commits — 147 ms, 1,907 reads, and it trips the whole-pack cache.Keyword.validate!/2would turn that into a visible error.Diff.diff_commits/4on a single-file commit is clean. HEAD~1..HEAD on hephaestus: 6 object reads, 1Myers.diff/2call, 5 ms, no measurable allocation. No repeated tree walking or blob re-reads on this shape. (This does not clear the large-diff path, which #48 already covers.)ReceivePack.feed/2chunk buffering is not quadratic. The:packphase uses an iolist accumulator (absorb/2) with O(1) append per chunk — the per-chunk SHA-1 rescan from PR #27 is gone. The remaining gap ispack_acc_to_binary/1materialising the whole pack once, already tracked as #153.Index.lookup_in_raw/2hex-decoding its argument per call is real but small. 244,664 calls per clone, butBase.decode16mixed!is only 42 of 2,964 samples (1.4%). Hoistingdecode_sha/1out of the per-pack loop infind_in_packs/3is a cheap follow-up, not a headline.
Suggested order
| # | finding | rank | measured effect |
|---|---|---|---|
| F7 | receive-pack keeps packs instead of exploding them |
2 | 59× on the push store path; stops production regenerating the loose layout |
| F3 | verbatim pack-entry + delta reuse in Pack.Writer |
2 | 11× by git’s own measurement; removes 76.5% of clone CPU; −38% pack bytes |
| F2 | walk carries content once, blobs read lazily | 3 | −50% object reads / index lookups / delta applies; −502 MB peak; −11% wall |
| F5 | drop String.upcase from Tree.encode_content/1 |
1 | −4.9% of clone CPU, one line |
| F1 | raw file I/O in Storage.Filesystem |
1 | 10.1× read throughput at concurrency 8 |
| F6 | push ref-prefix into Ref.list/2 |
2 | 209 ms → proportional, on every git fetch |
| F4 | fix the pack-reuse predicate, or delete the fast path | 3 | removes a discarded full graph walk per clone |
| F8 | drop the whole-pack cache once F3 lands | 5 | −1.05 GB resident per concurrent fetch |
F7 and F3 are a pair: F7 puts repos into a layout where F3 has something to reuse, and F3 is what makes that layout pay off. Neither is worth much without the other.
The F2 + F5 experiment is a working patch that passes the 112 protocol tests; it is on hand and can be raised as a PR on request. Everything else is a proposal, not code.
Round 2 — graph walks, diff rendering, receive-pack advertisement
A second pass over walk.ex / graph.ex / graph/fallback.ex / diff.ex / receive_pack.ex. Every item below was verified in the code and measured; two candidate findings did not survive measurement and are recorded as rejected at the bottom. Same corpus and method as the top post.
F9 — Fallback.ancestor?/4 reads the entire history to answer a one-hop question
Rank 2 — algorithmic class. Largest single ratio found in this pass.
graph/fallback.ex:128-131:
with {:ok, desc_anc} <- collect_ancestors(repo, descendant_sha, max_walk) do
{:ok, MapSet.member?(desc_anc, ancestor_sha)}
end
It materialises the complete ancestor set — one ObjectResolver.read per commit — and only then tests membership. There is no early exit and no pruning, so the cost is the whole history regardless of how close the answer is.
Measured on hephaestus, asking whether HEAD’s immediate parent is an ancestor of HEAD:
Fallback.ancestor?(parent_of_HEAD, HEAD) -> 1,907 object reads, 27 ms
1,907 reads for an answer that is one edge away.
The correct shape already exists in this codebase — Graph.ancestor?/3 uses bfs_find/5 (graph.ex:250-269), which returns on sha == target and prunes by generation number. The fallback needs the same early exit; generation pruning is unavailable without the commit-graph (#26) but the early exit alone is unconditional.
F10 — rev_list_range/4 reads in-range commits three times and discards one full pass outright
Rank 3 — avoid redundant work.
Three reads of every in-range commit:
Fallback.collect_ancestors(head)reads it during the reachability walk (fallback.ex:164) — but returns only SHAs, dropping the%Commit{}it just parsed.sort_by_committer_time_desc/2(fallback.ex:177-184) re-reads each SHA solely to pull.committer, and sorts.load_range_commits/2(walk.ex:357-364) reads them all a third time to rebuild the structs.
Pass 2 is not merely redundant — its output is discarded. walk.ex:305-307 does:
shas
|> MapSet.new() # <- committer-date ordering destroyed here
|> topo_oldest_first(commits)
topo_oldest_first/2 takes a MapSet and runs its own Kahn sort keyed on committer date. The ordering commits_between/3 computed cannot survive MapSet.new/1, so the extra read pass and the sort have zero effect on the result.
Measured, hephaestus, a 239-commit range:
Walk.rev_list_range(base..head) -> 239 commits, 4,053 ObjectResolver.read calls, 90 ms
of which commits_between/3 -> 239 shas, 3,814 reads, 52 ms
4,053 reads to return 239 commits — 17× the size of the answer. Breakdown: 1,668 (base ancestor walk) + 1,907 (head ancestor walk) + 239 (discarded sort pass) + 239 (load_range_commits).
Carrying the %Commit{} out of do_collect/4 instead of only its SHA removes passes 2 and 3 entirely — 4,053 → 3,575 reads, and deletes sort_by_committer_time_desc/2 and its sort as dead code.
The remaining 3,575 is the two-full-history-walk formulation (ancestors(head) \ ancestors(base)); base is an ancestor of head here, so the head walk re-reads all 1,668 base ancestors. git’s paint_down_to_common stops once the frontier is entirely common. That is the commit-graph work in #26, not a quick fix.
Same discarded-sort shape on the graph path, though cheap there because it is in-memory: Graph.commits_between/3 sorts by corrected commit date descending (graph.ex:228-233), and rev_list_range_graph/5 immediately re-sorts by {ccd, gen, sha} ascending (walk.ex:337-341).
F11 — ahead_behind_many/4 does not implement the bound its own docstring claims
Rank 2 — algorithmic class.
The docstring (graph/fallback.ex:54-70) promises:
This walks base once and then walks each head with early termination when entries already in
base_ancare reached, dropping the cost to O(|ancestors(base)| + Σ |ancestors(head_i) \ ancestors(base)|).
The body (fallback.ex:89) is:
case collect_ancestors(repo, head_sha, max_walk) do
collect_ancestors/3 takes (repo, sha, max_walk). There is no pruning parameter, and do_collect/4 has no knowledge of base_anc. Every head performs a full independent walk of the entire history. The only thing saved versus the naive caller pattern is one base walk.
Measured, hephaestus, 20 heads against one base:
ahead_behind_many(base, 20 heads) -> 39,618 reads, 601 ms
naive ahead_behind/4 x20 -> 71,310 reads, 1,065 ms
batch saves 44.4%
39,618 / 20 ≈ 1,981 reads per head — the full 1,907-commit history each time, exactly as if no batching existed. Against the documented bound (1,668 base ancestors + at most 239 ahead per head ⇒ ≤ 6,448 worst case) it is 6× over on the most pessimistic reading, and the realistic bound is nearer 1,900 reads, i.e. ~21× over.
The graph implementation does thread base through properly (graph.ex:363-401), so the contract is achievable — the fallback just needs base_anc passed into the walk as a stop set. Relevant to #60.
F12 — format_hunk_lines/3 is O(L²) per hunk on the diff render path
Rank 1 — per-unit cost in a shared primitive. Every rendered diff pays this.
diff.ex:150-159:
|> Enum.flat_map(fn {{type, line}, idx} ->
remaining = Enum.drop(lines, idx + 1) # O(L) allocation, per line
...
markers = no_newline_markers(type, remaining, no_newline_old, no_newline_new)
Enum.drop/2 builds a fresh suffix list for every line, and no_newline_markers/4 then scans that suffix up to twice (no_more_old_lines?/1 and no_more_new_lines?/1, diff.ex:209-216) — roughly 3·L²/2 list traversals per hunk. All of it to answer “is this the last line on the old/new side”, which is two constants for the whole hunk.
Measured (single hunk, deterministic 40/30/30 context/add/del mix):
| hunk lines | before | after | speedup |
|---|---|---|---|
| 500 | 0.16 ms | 0.04 ms | 4× |
| 1,000 | 0.56 ms | 0.14 ms | 4× |
| 2,000 | 1.72 ms | 0.22 ms | 7.8× |
| 4,000 | 6.62 ms | 0.45 ms | 14.7× |
| 8,000 | 31.76 ms | 0.82 ms | 39× |
| 16,000 | 179.65 ms | 2.94 ms | 61× |
Per-line cost degrades from 0.32 µs to 11.23 µs across that range; after the fix it is flat at ~0.1 µs/line.
The fix is one backward pass recording the index of the last :del/:context line and the last :add/:context line, then comparing idx against those two integers — O(L) total, and it deletes no_newline_markers/4, no_more_old_lines?/1 and no_more_new_lines?/1. Implemented and measured; 29 diff tests pass.
16,000-line hunks are not exotic — a lockfile, a generated file, a vendored dependency, a bulk deletion. This runs per hunk, per file, on every diff render.
F13 — Every git push reads every tag before the client sends a byte
Rank 2 — work scaled to the whole dataset for a slice-sized question. Same shape as F6, different protocol.
ReceivePack.init/1 → list_all_refs_with_head/1 (receive_pack.ex:317-328) lists all heads and tags, then add_peeled_tags/2 (:337-355) calls peel_tag → ObjectResolver.read for every refs/tags/* entry, to emit ^{} peeled lines.
Measured on the 5,001-ref repo:
ReceivePack.init/1 -> 5,000 ObjectResolver.read calls, 304 KB advertisement, 829 ms
Pushing one commit to a tag-heavy repo pays 5,000 object reads up front. (Timing caveat: on this synthetic repo the tag SHAs do not resolve, so 829 ms is the miss path — two file-server round trips per tag. With real annotated tags the read count is identical and each read additionally parses an object. The 5,000-reads figure is the solid number; treat 829 ms as indicative.)
Peeled lines are only needed for annotated tags. A cheap object-type probe (#23, has_objects/2) or storing peel results in packed-refs — which is exactly what git’s packed-refs ^ lines are for, and Storage.Filesystem.parse_packed_refs/1 already skips them at filesystem.ex:540 — avoids the reads entirely.
Minor, confirmed, not worth its own finding
Atomic push reads each ref twice. validate_ref_state/2 (receive_pack.ex:761/769/777) does Ref.get(repo, ref), then atomic_commit_phase/1 (:681-682) does Ref.get(state.repo, cmd.ref) again to snapshot for rollback. Same shape as F2, but bounded by the number of refs in the push (typically 1–2), so it is 2–4 extra file-server round trips — real, immaterial. Worth folding into any F1 work rather than fixing on its own.
Graph.push_parents/5 enqueues duplicates. graph.ex:313-322 calls insert_by_gen(q, ...) for every parent unconditionally, with no check against popped or the existing queue. walk_ahead_behind/6 dedups correctly on pop (graph.ex:295-299), so results are right, but the queue grows to O(edges) and each insert_by_gen is O(queue) — O(E²) on merge-heavy history. Not measured: hephaestus is not merge-heavy enough to exercise it, so I am recording the code shape without a number rather than inventing a corpus to justify it.
Measured and REJECTED (round 2)
-
zlib stream churn in
receive_pack.zlib_compress/1is not a cost. The per-object:zlib.open/deflateInit/deflateEnd/:zlib.closecycle (receive_pack.ex:897-910) looks like 134k port create/destroy cycles on a large push. Measured over 20,000 objects: 498.3 ms per-object vs 499.2 ms with one reused stream +deflateReset— 1.0×, no difference.:zlib.open/0is a NIF resource in modern OTP, not a port; the deflate work dominates entirely. The real saving on this path is not compressing at all, which is F7. -
ReceivePack.feed/2commands-phase re-parsing is immaterial.receive_pack.ex:162-198does concatenate the buffer and re-decode from byte 0 on every chunk, so it is O(C²) in chunk count — but C is the number of ref-update command lines, typically 1–2, and the whole command block is well under one SSH frame. Not worth changing. (The pack phase is correctly O(1) per chunk, as recorded in the top post.)
Revised order
F12 and F9 slot in high: both are single-function changes with large measured ratios on paths that run constantly.
| # | finding | rank | measured effect |
|---|---|---|---|
| F7 | receive-pack keeps packs instead of exploding them |
2 | 59× on the push store path |
| F3 | verbatim pack-entry + delta reuse | 2 | 11× by git’s own measurement; 76.5% of clone CPU |
| F12 | O(L) hunk formatting | 1 | 61× at 16k lines; every diff render |
| F9 | early exit in Fallback.ancestor?/4 |
2 | 1,907 reads → 1 for a one-hop question |
| F2 | walk carries content once, blobs read lazily | 3 | −50% reads, −502 MB peak |
| F11 | ahead_behind_many/4 honours its documented bound |
2 | ~21× over the bound today |
| F5 | drop String.upcase from Tree.encode_content/1 |
1 | −4.9% of clone CPU, one line |
| F1 | raw file I/O in Storage.Filesystem |
1 | 10.1× read throughput at concurrency 8 |
| F10 | carry %Commit{} out of the ancestor walk |
3 | 4,053 → 3,575 reads; deletes a dead sort pass |
| F6 / F13 | push ref-prefix down; stop peeling every tag on push |
2 | 209 ms per fetch; 5,000 reads per push |
| F4 | fix the pack-reuse predicate, or delete the fast path | 3 | removes a discarded full graph walk per clone |
| F8 | drop the whole-pack cache once F3 lands | 5 | −1.05 GB resident per concurrent fetch |
The F12 patch is working and passes the 29 diff tests; like the F2+F5 patch it is on hand and can be raised as a PR on request. Nothing has been pushed.