How We Made APT and dpkg Faster

Updates come with some unavoidable chores. APT downloads repository indexes, verifies files and solves dependencies. dpkg then unpacks thousands of files and makes sure they will survive a crash.

We wanted to know how often those chores were being done twice, or one at a time when they did not need to be.

PikaOS follows Debian Sid and rebuilds a large part of its archive with our own optimisations. APT and dpkg sit underneath nearly every update, build and new installation we run. Small delays get repeated a lot.

We did not want to replace either tool. They already understand decades of Debian packaging behaviour. We wanted to find the repeated work and long waits inside them, remove those, and leave their results and safety rules alone.

The result is a series of 50 patches for APT 3.3.3 and 13 for dpkg 1.23.7.

Measuring It Without Fooling Ourselves

Our first container benchmarks looked good. They were also useless for the most interesting dpkg change.

The transient container filesystem made fsync almost free, while its security profile blocked io_uring. That meant the benchmark was timing neither the storage waits users get nor the new path we wanted to test. We moved the I/O runs into a PikaOS chroot on plain ext4, measured that fsync was genuinely reaching the filesystem, and recorded whether io_uring was available with every result.

The benchmark runner gives the two builds random X and Y labels. It runs them back to back in alternating order, calculates the result, then opens the file that says which one was ours. A busy host can still make both runs slower, but pairing them keeps the same load spike on both sides of the comparison.

Every scenario also has a result checksum. Download tests must fetch the same packages. Solver tests must choose the same package actions. Install tests compare installed package versions and the files written under /usr. If the checksum changes, the faster time does not count.

Chart showing the wall time remaining after the PikaOS APT and dpkg patches, with upstream fixed at 100 percent

The final PikaOS chroot runs cut wall time by 22% for downloads, 13% for a full install and 18% for apt update. The dependency solve used 40% of the old time. dpkg's own install path fell by 14.6% with normal durability enabled.

Downloading in Parallel Without Overdoing It

APT used to compute every hash published by an archive for every downloaded byte. Debian repositories normally list MD5 and SHA256, even though verification only needs the strongest trusted match. We now compute that strongest hash by default. The security check is the same, but the download path no longer hashes the same file twice. That removed 54% of its CPU use in the complete download test.

Network waiting needed a different fix. We first taught APT to open several connections to the same host. That helped on links with more latency, so an early version used ten connections. Then we tried it against the two CDNs that matter to us.

Ten connections were awful. They took 2.16 times as long as upstream on the PikaOS CDN and 5.10 times as long on Debian's. Three connections were around 40% faster on both.

Higher-is-better chart comparing one, three and ten HTTP 1.1 connections with one HTTP 2 connection on the PikaOS and Debian CDNs

Opening more connections was the wrong way to get parallel downloads on a modern CDN. We wrote a new libcurl-based HTTP/2 acquisition method instead. It keeps one connection open and multiplexes up to ten package requests at once rather than opening ten separate HTTP/1.1 connections.

Across the 50-package real-CDN matrix, that path reached 3.85 times the single-connection speed on ppa.pika-os.com and 4.35 times on deb.debian.org. We settled on three connections for APT's built-in HTTP/1.1 method and one multiplexed connection for HTTP/2.

The HTTP/2 transport still handles APT's proxy settings, authentication, resumed downloads, rate limits and redirect policy. It refuses an HTTPS to HTTP redirect in the same place as the built-in transport, so the redirect and TLS checks stay the same.

Making apt update Do the Work Once

An update has several jobs that do not depend on each other. APT can decompress repository data while signature helpers and local store operations run, but some of those helpers were forced through one serial queue. We let them run together.

Cache generation also grew its memory map in 1 MiB steps. A large cache could be remapped around 180 times before it reached its final size. Geometric growth cuts that to roughly five. We moved a few constant configuration lookups out of the inner package loop as well.

The biggest improvement happens when the repositories have not changed. APT still downloaded nothing, then rebuilt its caches anyway. It now keeps the existing cache after a clean no-change update.

With a 57 MiB cache, that path went from 0.66 seconds to 0.01. With a 290 MiB cache, it went from 2.3 seconds to 0.02. Cold updates still improve, but not by two orders of magnitude because they have real new data to process. The complete cold-update benchmark was 18% faster.

Spending Less Time Solving the Same Problem

APT's simulation path was repeatedly scanning the whole package cache for each operation it printed. A simulated full upgrade against Sid took 4.34 seconds. After keeping the affected records directly, it took 0.79 seconds and produced byte-for-byte identical output.

We also memoised exact dependency version comparisons, stopped sorting every package name when only the broken set mattered, and avoided building conflict explanations that would be discarded on the next solver backtrack. On a failing solve, those unused explanations accounted for about 74% of the run time.

The final dependency scenario was 2.5 times faster. An earlier heavier simulation reached 6.2 times faster, although we use the quieter final chroot result for the main chart.

Profiling the solver exposed a few bugs too. Its timeout could trigger a SIGABRT during setup, and its clock could start late or reset during the solve. It now uses one monotonic deadline and reports the last real conflict when it stops.

Teaching dpkg to Wait in Batches

dpkg had its own repeated work. It generated an MD5 digest for every extracted file even when the package already supplied the digest that dpkg would use. It could reload and sort file lists for every installed package after changing one of them. We removed both loops, then added one-package lookahead so the next archive can decompress while the current one is unpacked.

Storage was the larger problem. In one profile, dpkg spent 22.5 seconds of a 23.4 second unpack blocked inside fsync. Those calls are there for a reason. A package file must reach stable storage before dpkg renames it into place.

We kept that rule. The new path submits a package's file syncs together through io_uring, waits for every result, then performs the renames. A run that previously waited on 15,671 extracted-file syncs one by one grouped them into 554 ring submissions instead.

Diagram showing dpkg changing from one fsync wait per file to batched io_uring submissions while keeping the same durability rule

The ordering is slightly stronger than before: every file in a batch is durable before any file in that batch is renamed. If the kernel or container does not allow io_uring, dpkg uses the existing serial path. The fallback is compiled and tested as a normal path, not treated as an error.

Across 371 real PikaOS packages, unpacking and configuration took 14.6% less wall time. With --force-unsafe-io, a separate 263-package unpack fell from 7.48 seconds to 4.75, mostly from avoiding discarded digests and overlapping decompression. That option deliberately changes durability, so it is not the number we use for the normal install claim.

Checking the Boring Parts

The APT tree passed 338 tests with no failures and six of upstream's existing solver skips. The dpkg work passed 3,989 unit assertions, 74 autotests and 84 root-only functional tests in both synchronization modes.

We also compared the output directly. The dpkg filesystem tree, per-file hashes, administration database and program output matched upstream across real Debian packages and synthetic packages containing the awkward things: hard links, fifos, device nodes, long paths, conffiles and shared multi-arch files.

For APT, the benchmark harness checked the available package set, downloaded archives, installed files and solver actions on every A/B run. We built a null version of our APT package with the switchable improvements disabled too. It tied upstream on the affected scenarios, which told us the harness was measuring the patches rather than a difference between the two build environments.

We checked the public ABI as well. Software built against upstream libapt-pkg continues to work with ours: existing member offsets did not move, MethodConfig stayed byte-identical, new Worker members were appended, and new exports were added to debian/libapt-pkg7.0.symbols with their minimum versions.

Shipping It

The patched APT and dpkg builds have gone through PikaOS canary and nest-testing using the same package pipeline described in our Neo Brunel post. The commands and package formats have not changed, and existing APT configuration still works.

Both patch sets are public. The current series lives in upstream-patches/apt and upstream-patches/dpkg, with the rebasing trees in repo-tools/apt and repo-tools/dpkg.

After all those patches, an APT download still verifies the same bytes and dpkg still refuses to rename an unsynced file. The wait is shorter because both tools repeat less work and spend less time queuing it one operation at a time.