Skip to content
academia.sh

Lesson 10 / 19

Network and Battery Profiling

The number of requests sent in a synchronization cycle determines battery and network use. The lesson measures sending requests one by one, in batches and by polling on the same stream of changes, and shows that polling exceeds the budget even in cycles without changes.

Published Updated

In this lesson

  • Explains how a network request is tied to battery use through the concepts of wakeup and batching
  • Measures from the model the number of requests sent in a synchronization cycle for single and batched sending
  • Shows that polling sends a request in every cycle, even in cycles that bring no change
  • Distinguishes which saving the request-count rule sees and which it cannot
Contents

Memory was what the app held inside itself. Network and battery are what the app spends toward the outside. A leak fills memory silently; small requests sent often also drain the battery silently. The user does not see this on a single screen; they notice it at the end of the day, by how quickly the battery ran out. So the question of this lesson is this: when synchronization runs, how many requests go out in one cycle, and in how many cycles does this number exceed the budget.

The battery cost of a request comes less from the data it sends than from the device waking up to send it. While the device is idle the network hardware waits at low power; a request puts the hardware into a wakeup state, and it stays awake for a while after the request ends. Sending three small requests one after another is usually more expensive than sending the same three changes in a single request, because the wakeup is paid once and not three times. Collecting changes and sending them together is called batching. Asking the server at regular intervals “is there anything new” is called polling.

The measure is the course’s measure.

// Overrun.kt — classifies in how many samples a measurement exceeds its budget
class Budget(val name: String, val limit: Int)

class Overrun(val label: String, val budget: Budget, samples: List<Int>) {
    val total: Int = samples.size
    val over: Int = samples.count { it > budget.limit }
    val verdict: String = when {
        over == 0 -> "within"
        over == total -> "all"
        over * 2 < total -> "minority"
        else -> "majority"
    }

    fun line() {
        println("$label: budget=${budget.name}<=${budget.limit} | over=$over/$total -> $verdict")
    }
}

fun countOverruns(overruns: List<Overrun>) {
    for (name in listOf("within", "minority", "majority", "all")) {
        println("$name: ${overruns.count { it.verdict == name }}")
    }
}

Budget carries the name and limit of a measurement, and Overrun counts the samples that exceed the limit and gives one of four overruns. This file is the same in every lesson of the course.

The Synchronization Cycle Model

In the model a cycle is one round of synchronization. In every round a certain number of local changes arrive in the app: a note the user added, a record that was deleted, a setting that changed. We call these numbers the arrivals; let the stream of eight rounds be 3, 0, 5, 1, 0, 4, 2, 0. The synchronization mode determines how many requests these changes turn into.

// Sync.kt — counts the requests sent in a synchronization cycle
fun requestCount(arrivals: List<Int>, mode: String): List<Int> =
    arrivals.map { count ->
        when (mode) {
            "single" -> count
            "batched" -> if (count > 0) 1 else 0
            "polling" -> 1
            else -> 0
        }
    }

requestCount returns, for each round, the number of requests sent in that round. In the single mode every change is a separate request. In the batched mode the changes accumulated during the round go in a single request, and if there is no change no request goes. In the polling mode one request goes in every round, whether a change arrives or not. We first measure sending one by one, with a request budget of two.

// Main.kt — every change is sent as a separate request
fun main() {
    val arrivals = listOf(3, 0, 5, 1, 0, 4, 2, 0)
    val samples = requestCount(arrivals, "single")
    println("requests: $samples")

    val overrun = Overrun("single", Budget("request", 2), samples)
    overrun.line()
    countOverruns(listOf(overrun))
}
requests: [3, 0, 5, 1, 0, 4, 2, 0]
single: budget=request<=2 | over=3/8 -> minority
within: 0
minority: 1
majority: 0
all: 0

Three rounds exceeded: the rounds in which three, five and four changes arrived. The round with two changes was not counted as an overrun, because it is equal to the limit. Since three of the eight rounds exceeded, the result is minority.

PD1. The model maps one change to one request and counts every request with the same weight. In reality requests differ greatly from one another in body size, connection setup and retries. The model counts the requests sent; it does not set up how long a request stays on the network, how much data it carries or how much battery it takes.

Batching

Accumulating the same stream of arrivals and sending it in a single request separates the number of requests from the number of changes.

// Main.kt — the same changes are sent in a single request per round
fun main() {
    val arrivals = listOf(3, 0, 5, 1, 0, 4, 2, 0)
    val samples = requestCount(arrivals, "batched")
    println("requests: $samples")

    val overrun = Overrun("batched", Budget("request", 2), samples)
    overrun.line()
    countOverruns(listOf(overrun))
}
requests: [1, 0, 1, 1, 0, 1, 1, 0]
batched: budget=request<=2 | over=0/8 -> within
within: 1
minority: 0
majority: 0
all: 0

No round went past two requests: within. The same fifteen changes went with five requests. The difference between the earlier measurement and this one is the number of rounds that exceeded the budget, and batching lowered this number from three to zero.

On the platform, batching is done by handing the work over to the system’s job scheduler: the app registers the work with conditions that say when it should run, and the system can run the work of many apps in the same wakeup window.

// Platform code: conditional periodic synchronization
import android.content.Context
import androidx.work.Constraints
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.NetworkType
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import java.util.concurrent.TimeUnit

fun scheduleSync(context: Context) {
    val constraints = Constraints.Builder()
        .setRequiredNetworkType(NetworkType.UNMETERED)
        .setRequiresBatteryNotLow(true)
        .build()

    val request = PeriodicWorkRequestBuilder<SyncWorker>(1, TimeUnit.HOURS)
        .setConstraints(constraints)
        .build()

    WorkManager.getInstance(context)
        .enqueueUniquePeriodicWork("sync", ExistingPeriodicWorkPolicy.KEEP, request)
}

This piece is platform code, and here we claim no output, duration or battery figure. The conditions ask for the work to run only on a suitable network and when the battery is not low; the system starts the work at a moment when the conditions are met. The model sets up the rule of this measurement; the model is not the tool itself. How the system really combines jobs is information the model does not show.

Polling

Polling is asking the server whether or not there is a change. If changes arrive often, polling may look reasonable, but it wakes the device even at times when no change arrives. To measure this, we set up a stream in which all the rounds pass quietly: no round brings a change.

// Main.kt — on a stream with no changes, three modes are measured with the same budget
fun main() {
    val quiet = List(8) { 0 }

    val overruns = listOf("single", "batched", "polling").map { mode ->
        Overrun(mode, Budget("request", 0), requestCount(quiet, mode))
    }
    overruns.forEach { it.line() }
    countOverruns(overruns)
}
single: budget=request<=0 | over=0/8 -> within
batched: budget=request<=0 | over=0/8 -> within
polling: budget=request<=0 | over=8/8 -> all
within: 2
minority: 0
majority: 0
all: 1

With no changes, the single and batched modes sent no requests at all, while polling sent a request in eight of the eight rounds: all. The cost of polling does not depend on the number of changes; it depends on time. Even if the user does not use the app, polling makes you pay for a wakeup in every round.

PD2. In the model polling is exactly one request per round. In reality the polling interval can be lengthened, the server can give the “no change” answer with a very small response, and the system can run the request in the same window as other work. These mitigations are not in the model, so polling coming out all is close to the worst case of a real app.

Why We Count Requests

Battery consumption is not measured directly, because there is no reliable way to measure it in this repository. Instead we count one of the behaviors that affect the battery: the number of requests sent. This choice has a cost. The request count does not stand in for the battery; it is only a proxy related to it. Two requests sometimes come cheaper than one large request, sometimes more expensive. For this reason the lesson gives no number about the battery and mentions it only through the concept of wakeup.

The reason the request count is a good measure is that it ties directly to a design decision. Batching or polling is a decision the developer makes, and the request count is the result of this decision. The measurement makes it possible to read the effect of the decision from inside the code.

The quiet-stream measurement is kept separate for the same reason. A real app is not used heavily all the time; for most of the night the device is idle and no change reaches the app. What drains the battery in those hours is not the work the user does but the work the app does by itself. Polling is exactly this kind of work. That is why a synchronization design is tested not only on a busy stream but also on a quiet one.

Work handed over to the job scheduler also raises a design question: how long the work can be delayed. Saving a note can be delayed a few minutes; a message cannot be delayed. Loosening the conditions raises the system’s chance of combining the work, tightening them lowers it. The model does not set up this bargain; it gives only the request count and does not measure delay.

Three questions are asked in choosing the synchronization mode. How often do changes arrive, how much delay does the other side accept, and is synchronization needed while the app is closed. With frequent changes the gain from batching grows, because each batched request carries more changes. In a stream that does not accept delay, batching gives way to immediate sending, and the budget is loosened in that case. If synchronization is wanted while the app is closed too, the work is registered as work that can run without the app process. The three modes in the model are the plainest choices matching the answers to these three questions.

When interpreting the measurement in production, the state of the device also enters the account. The same request comes cheap when the network hardware is already awake and expensive when it is idle. This difference does not enter the model: the model counts every request as independent. That is why the request count is a sound measure for comparing two designs but insufficient to state the real battery cost of a design.

In batching, the length of the window is also a setting. As the window lengthens, each batched request carries more changes and the request count falls; but the delay before the changes reach the other side also grows. In the model the window is one round long and is not changed. In a real app, choosing the window means striking a balance between request count and delay, and only the product’s requirement determines this balance.

Failure cases also affect the request count. If a failed request is retried, a second wakeup is paid for the same change. A design whose retry interval lengthens limits this cost. The model counts every request as successful and does not include retries; so the request count of a real stream may be larger than what the model gives.

Finally, the mode in which the measurement was taken is written in the report. The same stream gives three different numbers under three modes, and someone reading the number alone cannot know which design was measured. Printing the mode name, the budget and the total request count together makes it possible to interpret a line afterward; in the lesson’s outputs these three appear in every line.

Common Misconceptions

“Polling is harmless in apps where changes are frequent.” If changes arrive often, the wasted rounds of polling decrease, but they do not become zero. In the lesson’s quiet-stream measurement, polling sent a request in every round even when there was no change.

“Batching is always better.” Batching delays the sending of the changes. In a stream where a change must reach the other side instantly, this delay may be unacceptable. Because the lesson measures only the request count, it does not show the delay; this does not mean batching is free.

“The same overrun means the same saving.” Under a budget of zero, single sending and batched sending gave the same overrun, yet one sent fifteen requests and the other five. The rule does not see this difference; it becomes visible when the total is printed separately.

“The platform combines the work, so there is no need to measure.” The system can bring work together in a suitable window, but how many jobs the app registers and how often they are asked to run is still the app’s decision. The system’s share of combining does not reduce the registered work itself.

How to Choose a Budget

The request budget is the number of requests allowed per round. Setting the budget to zero demands that no round send a request; this makes every round in which a change arrives an overrun and leaves only the quiet rounds clean. Setting the budget to one accepts a single request per round; this keeps batching within and leaves sending one by one, in this stream, in the majority. In the lesson’s first two measurements the budget was two. The value chosen somewhere between them depends on how much delay the app accepts.

The Bounding Measurement

So far we have seen two things: batching lowers the request count, and polling sends requests even on a quiet stream. To see what the rule cannot see, we measure the same stream of arrivals with the three modes, this time with a budget of zero, and print the total request count as well.

// Main.kt — the same stream is measured with three modes at a budget of zero and the total requests are printed
fun main() {
    val arrivals = listOf(3, 0, 5, 1, 0, 4, 2, 0)

    val overruns = listOf("single", "batched", "polling").map { mode ->
        val samples = requestCount(arrivals, mode)
        println("$mode total: ${samples.sum()}")
        Overrun(mode, Budget("request", 0), samples)
    }
    overruns.forEach { it.line() }
    countOverruns(overruns)
}
single total: 15
batched total: 5
polling total: 8
single: budget=request<=0 | over=5/8 -> majority
batched: budget=request<=0 | over=5/8 -> majority
polling: budget=request<=0 | over=8/8 -> all
within: 0
minority: 0
majority: 2
all: 1

Under a budget of zero, single and batched gave the same overrun: both majority. Yet one sent fifteen requests and the other five. The rule looks at whether a round exceeded the budget, not at how many requests it sent in excess; so it cannot see, under this budget, the saving of batching, measured as ten requests. The same function gives two overruns under two budgets: with a budget of two single was minority, with zero it was majority. To see the saving you either look at the total count or pull the budget to a limit that batching can reach, that is, one request per round. The lesson’s first two measurements therefore drew a picture with two budgets: with a budget of two the difference is visible, with zero it disappears.

Polling came out all on its own in this measurement too, because it sent requests in the three rounds in which no change arrived. Eight requests are more than the five of batching and fewer than the fifteen of sending one by one; the rule saying all does not mean polling sent the most requests, it means it sent a request in every round.

PD3. The arrival stream in the model is fixed and has eight rounds. In real use changes arrive in clusters, interrupted by long quiet periods, and vary greatly by user. A result gained on one stream must be measured again on another stream before it is carried over.

Summary

  • This lesson’s overrun: within. With batching, the same changes turned into at most one request per round, and no round exceeded the two-request budget.
  • A request spends battery because it wakes the device; batching separates the number of wakeups from the number of changes.
  • Sending one by one exceeded the budget in three of the eight rounds on the same stream: minority.
  • Polling sent a request in every round even on a stream with no changes, and came out all at a budget of zero.
  • The rule counts whether a round exceeded the budget. At a budget of zero, fifteen and five requests gave the same overrun; to see the saving you must also look at the total count.

Next Step

Every number we have measured so far was measured once, and that one number was accepted. Yet measurement is noisy: running the same work twice can give different numbers. The next lesson, Benchmarking, covers how a regression is told apart in repeated measurements and why warm-up iterations do not take part in the measurement.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close