KEP-5598: Opportunistic batching
KEP-5598: Opportunistic batching
- Release Signoff Checklist
- Summary
- Motivation
- Proposal
- Design Details
- Production Readiness Review Questionnaire
- Implementation History
- Drawbacks
- Alternatives
- Future work
- Infrastructure Needed (Optional)
Release Signoff Checklist
Items marked with (R) are required prior to targeting to a milestone / release.
- (R) Enhancement issue in release milestone, which links to KEP dir in kubernetes/enhancements (not the initial KEP PR)
- (R) KEP approvers have approved the KEP status as
implementable - (R) Design details are appropriately documented
- (R) Test plan is in place, giving consideration to SIG Architecture and SIG Testing input (including test refactors)
- e2e Tests for all Beta API Operations (endpoints)
- (R) Ensure GA e2e tests meet requirements for Conformance Tests
- (R) Minimum Two Week Window for GA e2e tests to prove flake free
- (R) Graduation criteria is in place
- (R) all GA Endpoints must be hit by Conformance Tests within one minor version of promotion to GA
- (R) Production readiness review completed
- (R) Production readiness review approved
- “Implementation History” section is up-to-date for milestone
- User-facing documentation has been created in kubernetes/website , for publication to kubernetes.io
- Supporting documentation—e.g., additional design documents, links to mailing list discussions/SIG meetings, relevant PRs/issues, release notes
Summary
This KEP proposes an opportunistic batching mechanism in the scheduler to improve performance of scheduling many compatible pods at once, and to begin building the infrastructure required for gang scheduling. To implement this mechanism we propose the following additions:
- Pod scheduling signature: A signature that captures the properties of a pod that impact scoring and feasibility.
- Batching mechanism: A mechanism to reuse the scheduling output from one pod to provide node hints for multiple subsequent pods with matching scheduling signatures.
- Opportunistic batching: Transparent inclusion of the batching mechanism in the scheduler to improve the performance of targeted workloads that could benefit from it.
Motivation
Today our scheduling algorithm is O(num pods x num nodes). As the size of clusters and jobs continue to increase, this leads to low performance when scheduling or rescheduling large jobs. This increases user cost and slows down user jobs, both unpleasant impacts. Optimizations like this one have the potential to dramatically reduce the cost of scheduling in these scenarios.
Gang scheduling and other multi-pod scheduling mechanisms require a way to consider multiple pods at the same time. Opportunistic batching provides a starting point by introducing signatures and batch state reuse, both foundational mechanisms, and including them in a transparent and incremental way.
A common pattern in batch and ML environments is to run a single user pod on each node alongside a complement of daemonset pods. For these workloads, once a pod is placed on a node, that node is full and can be skipped for subsequent pods. This allows the scheduler to reuse not only filtering but also scoring results, reducing per-cycle cost.
Goals
- Improve the performance of scheduling large jobs on large clusters where the constraints are simple.
- Begin building infrastructure to support gang scheduling and other “multi-pod” scheduling mechanisms.
- Ensure that the infrastructure we build is maintainable as we update, add and remove plugins.
- Reduce per-cycle scheduling cost for a targeted set of workloads in this release.
- Provide a path where we can expand batching to apply to most or all workloads over the next few releases.
- Allow users to continue to use out-of-tree plugins. For this KEP we need to ensure that out-of- tree plugins continue to work without requiring edits, although they may not be able to take advantage of the new feature without some edits.
Non-Goals
- Applying this optimization to all pods. Batching is transparent but limited to signable workloads in this KEP.
- Adding gang scheduling. This is purely a performance improvement without dependency on the Workload API KEP-4671 , although this work is intended to build toward it.
Proposal
The batching mechanism is applied transparently to simple cases in the scheduling cycle, reusing the cached sorted node list from a previous cycle to provide node hints for subsequent identical pods. More complex integrations are left to future work.
The proposal covers two components: pod scheduling signature and batching mechanism.
Pod scheduling signature
The pod scheduling signature identifies pods that are equivalent from a scheduling perspective: any two pods with the same signature will receive identical scores and feasibility results for any given set of nodes. Assigning a pod to a node must not change the feasibility or scoring of other nodes. This is the invariant that allows cached results to be reused.
Pods whose scheduling depends on cross-pod state or global placement data, rather than only pod and node attributes, receive a nil signature and fall back to the standard full-pipeline path.
A new framework interface allows out-of-tree plugins to construct a signature. Each plugin returns a
set of signature fragments that capture the pod attributes relevant to that plugin’s scheduling
decisions. To construct a full signature, the framework collects fragments from all plugins and
marshals them into a single PodSignature (a byte slice). If any plugin cannot generate a signature
for a given pod, it returns an error status and a nil signature is produced for that pod, skipping
batching.
If any enabled plugin that participates in the PreScore, Score, PreFilter or Filter extension points does not implement this interface, batching is disabled for all pods.
The signature interface and types are defined as follows:
// A portion of a pod signature. The sign fragments from all plugins are combined
// to create a unified signature.
type SignFragment struct {
// Key identifies this fragment. Fragments with the same key should contain
// the same value for the same pod.
Key string
// Value must be JSON-marshallable.
Value any
}
// The signature for a given pod after all fragments are consolidated.
type PodSignature []byte
// SignPlugin is an interface that should be implemented by plugins that either filter
// or score pods to enable batching and gang scheduling optimizations.
type SignPlugin interface {
Plugin
// SignPod returns SignFragments for this pod.
//
// Return values:
// - Success: plugin can sign the pod, returns signature fragments
// - Unschedulable: plugin cannot sign pod (pod not eligible for batching)
// - Error: unexpected failure (pod not eligible for batching, error logged)
SignPod(ctx context.Context, pod *v1.Pod) ([]SignFragment, *Status)
}
Batching mechanism
The second component of this KEP is a batching mechanism. The batching mechanism provides two main operations that are invoked during the scheduling cycle:
- GetNodeHint: Returns a node hint for a pod with a valid signature by validating that cached scheduling results can be reused.
- StoreScheduleResults: Stores the sorted scheduling results from a “canonical” pod for potential reuse with subsequent matching pods.
GetNodeHint
The GetNodeHint operation is called during the filtering phase, after PreFilter but before
evaluating individual nodes, to determine if we can reuse cached scheduling results from a previous
pod. It takes a pod with a signature and attempts to provide a node hint that will allow the
scheduler to take a fast path.
Before returning a hint, the operation validates that the cached batch state is compatible with the current pod by checking:
- Cycle continuity: The current scheduling cycle must be exactly one greater than the last cycle (no other pods were scheduled).
- Signature match: The pod’s signature must exactly match the cached signature.
- Cache freshness: The cached data must be sufficiently recent to avoid relying on stale scheduling decisions.
- Last chosen node feasibility check: The node chosen in the previous scheduling cycle must now be infeasible for the new pod. This is verified by running filter plugins against that node. This validation ensures the one-pod-per-node constraint that allows us to reuse scoring results without rescoring.
If all checks pass, the operation pops the next best node from the cached sorted list and returns it as a hint (see Integration with Scheduling Cycle below for how the hint is used).
If any check fails, the batch state is invalidated with the reason recorded in metrics, no hint is provided, and the scheduler falls back to full evaluation of all nodes.
StoreScheduleResults
The StoreScheduleResults operation is called after a pod has been scheduled (after filtering,
scoring, and node selection). It stores scheduling results for potential reuse with subsequent pods.
The operation first records information about the last scheduling cycle (cycle number and chosen node) for use in the next cycle’s validation.
Then it determines whether to store new batch state:
If a hint was used (hintedNode == chosenNode):
- The cached result was reused successfully.
- No new batch state is stored; the existing batch continues.
- Statistics are recorded (batchedPods counter incremented).
If no hint was provided or the hint was not used:
- If the pod has a valid signature and there are remaining nodes in the sorted list, new batch state
is created containing:
- The pod’s signature
- The sorted list of remaining feasible nodes
- Creation timestamp (for the expiration check)
- If the pod has no signature or no remaining nodes, no batch state is stored.
The batch state is kept in memory only and is constrained to a short-lived validity window to prevent stale data from affecting scheduling decisions.
Integration with Scheduling Cycle
The GetNodeHint operation returns a hint string (node name) that is plugged directly into the
scheduling cycle.
During the scheduling cycle, if a node was hinted by the batching mechanism, the scheduler evaluates that specific node first before iterating over all nodes. This “try one node first” is the fast path. If the hinted node passes all filters, it is immediately returned as the only feasible node, bypassing evaluation of all other nodes and scoring entirely.
If the hinted node fails filtering, the scheduler falls back to the normal path of evaluating all nodes. This ensures correctness while providing significant performance benefits when the hint is valid. The batching mechanism can be used in multiple places, including future gang scheduling implementations, without requiring changes to the Pod API.
Notes/Constraints/Caveats (Optional)
Risks and Mitigations
Plugins need to keep signatures up to date
The cache requires plugin maintainers to keep their portion of the signature up-to-date. By putting the logic into the plugin interface itself and restricting it to portions of the pod spec, the surface area for divergence is minimized, but subtle dependencies can still creep in over time.
If signature drift becomes a problem, the signature could be codified as a reduced “Scheduling” object containing only the fields relevant to scheduling decisions. Plugins that opt in would receive only this object, making it structurally impossible for the signature and plugin logic to diverge. This approach is deferred because plugin changes are expected to be infrequent and the additional interface complexity is not justified at this stage.
Limited workload coverage
By limiting the supported feature set, batching may not apply to all workloads. This is mitigated by targeting simple patterns first and providing a clear expansion path via the signing mechanism and future group-aware plugin support.
No prior production history
Batching is a new code path with no prior production history. The built-in metrics provide visibility into how often batching triggers, how often the cache is invalidated and why. Feature gate is enabled by default, operators can disable it if unexpected behavior is observed.
Cached nodes are not re-filtered each cycle
Only lastChosenNode is re-filtered against the fresh snapshot each cycle. Other nodes in the
cached list are not re-checked, so a node that has become infeasible since the initial full pass may
remain in the list and be selected as a hint.
This is bounded by the existing maxBatchAge expiry (500ms): a batch that has been running longer
than 500ms is flushed and a full pipeline reruns. In a typical scheduling burst, where pods are
processed every few milliseconds, the window for meaningful node state drift is very small.
Operators can disable OpportunisticBatching to restore full-pipeline behavior if degradation is
observed.
Design Details
Pod signature
A pod scheduling signature is a hash of the pod’s scheduling requirements. It is used to identify pods that can be scheduled together. To optimize the scheduling cycle, the signature is calculated and cached when a pod first enters the scheduling queue. By pre-calculating the signature during the queuing phase, the scheduler avoids extra work during the time-sensitive scheduling process, allowing larger batches to be handled more smoothly.
The following section lists the pod attributes used as the signature for each plugin. Plugins that depend on cross-pod state or global placement data return an unsignable status; all others return a deterministic signature fragment covering the relevant fields.
Note that the signature does not need to be stable across versions, or even invocations of the scheduler. It only needs to be comparable between pods on a given running scheduler instance.
- DynamicResources: Pods with dynamic resource claims are marked unsignable. Most DRA claims are node-specific and could be made signable with additional work; this is deferred to a future iteration.
- ImageLocality: We use the canonicalized image names from the Volumes as the signature.
- InterPodAffinity: If either the PodAffinity or PodAntiAffinity fields are set, the pod is marked unsignable, otherwise the pod labels need to be included in the signature.
- NodeAffinity: We use the NodeAffinity and NodeSelector fields, plus any defaults set in configuration as the signature.
- NodeName: We use the NodeName field as the signature.
- NodePorts: We use the results from util.GetHostPorts(pod) as the signature.
- NodeResourcesBalancedAllocation: We use the output of calculatePodResourceRequestList as the signature.
- NodeResourcesFit: We use the output of the computePodResourceRequest function as the signature.
- NodeUnschedulable: We use the Tolerations field as the signature.
- NodeVolumeLimits: We use all Volume information except from Volumes of type ConfigMap or Secret.
- PodTopologySpread: If the PodTopologySpread field is set, or it is not set but a default set of rules are applied, we mark the pod unsignable, otherwise it returns an empty signature. Because the plugin itself is creating the signature, it knows whether and what default rules apply.
- TaintToleration: We use the Tolerations field as the signature.
- VolumeBinding: Same as NodeVolumeLimits.
- VolumeRestrictions: Same as NodeVolumeLimits.
- VolumeZone: Same as NodeVolumeLimits.
Test Plan
[X] I/we understand the owners of the involved components may require updates to existing tests to make this code solid enough prior to committing the changes necessary to implement this enhancement.
Prerequisite testing updates
Unit tests
Coverage of existing packages
Will add an extra function and test for plugins we touch.
k8s.io/kubernetes/pkg/scheduler:2025-10-7-86.1k8s.io/kubernetes/pkg/scheduler/framework:2025-10-7-51.8k8s.io/kubernetes/pkg/scheduler/framework/runtime:2025-10-7-84.3k8s.io/kubernetes/pkg/scheduler/framework/plugins/dynamicresources:2025-10-7-80.5k8s.io/kubernetes/pkg/scheduler/framework/plugins/imagelocality:2025-10-7-86.2k8s.io/kubernetes/pkg/scheduler/framework/plugins/interpodaffinity:2025-10-7-89.7k8s.io/kubernetes/pkg/scheduler/framework/plugins/nodeaffinity:2025-10-7-85.8k8s.io/kubernetes/pkg/scheduler/framework/plugins/nodename:2025-10-7-50k8s.io/kubernetes/pkg/scheduler/framework/plugins/nodeports:2025-10-7-83.7k8s.io/kubernetes/pkg/scheduler/framework/plugins/noderesources:2025-10-7-89.5k8s.io/kubernetes/pkg/scheduler/framework/plugins/nodeunschedulable:2025-10-7-87.1k8s.io/kubernetes/pkg/scheduler/framework/plugins/nodevolumelimits:2025-10-7-73.7k8s.io/kubernetes/pkg/scheduler/framework/plugins/podtopologyspread:2025-10-7-87.8k8s.io/kubernetes/pkg/scheduler/framework/plugins/tainttoleration:2025-10-7-86.9k8s.io/kubernetes/pkg/scheduler/framework/plugins/volumebinding:2025-10-7-83.9k8s.io/kubernetes/pkg/scheduler/framework/plugins/volumerestrictions:2025-10-7-74k8s.io/kubernetes/pkg/scheduler/framework/plugins/volumezone:2025-10-7-84.8
New unit tests
schedule_one_test.go- Add test cases for opportunistic batching.signature_test.go- Test cases for the framework signature call and the helper class.signature_consistency_test.go- Test cases to ensure the signature captures all the necessary information. A range of pod specs and node definitions are run through the filtering/scoring code; pods with matching signatures must always receive equivalent results.batching_test.go- Test cases for the batching mechanism, separate from the actual integration into the scheduling pipeline.
Integration tests
Integration tests:
scheduler_perftests measuring scheduling throughput with batching enabled and disabled for representative scenarios.- End-to-end consistency: Tests running pods through the scheduler end-to-end with batching enabled and disabled, verifying that scheduling decisions are the same.
e2e tests
- Run the existing scheduling e2e tests with batching enabled and disabled, to ensure they pass in both cases.
- Add e2e tests ensuring that pod configurations we expect to be batched are in fact batched.
Graduation Criteria
Beta
- Feature implemented behind a feature flag
- Initial signature implementations for all in-tree plugins (note that some, as described in the section, will always return unsignable if the pod is configured to use them).
- Monitoring
- Hand-done perf test runs
- Integration tests
- Initial e2e tests completed and enabled
- Handle common one-pod-per-node batches: host ports and resources
- Parameter tuning (batch sizes, etc.)
- Pod scheduling signature is cached and reused across scheduling attempts, eliminating per-cycle recomputation.
- Excluded: batching for non “one-pod-per-node” workloads
GA
- At least one production deployment with evidence of improved scheduling throughput.
Upgrade / Downgrade Strategy
Users should continue to see the same behavior, just with better performance. All batching state is in-memory only.
Version Skew Strategy
This feature should be localized to the scheduler. So long as the scheduler is correctly built, we should not require other interactions from components in the system. Scheduler plugins will need to implement new methods to take advantage of the feature, but if they do nothing the feature will simply end up disabled.
Production Readiness Review Questionnaire
Feature Enablement and Rollback
How can this feature be enabled / disabled in a live cluster?
- Feature gate (also fill in values in
kep.yaml)- Feature gate name:
OpportunisticBatching - Components depending on the feature gate:
kube-scheduler
- Feature gate name:
Does enabling the feature change any default behavior?
No, it should not. Batching will improve the performance of some workloads, but should be transparent otherwise.
Can the feature be disabled once it has been enabled (i.e. can we roll back the enablement)?
Yes, it can be disabled. Because it only keeps in-memory state, setting the flag to false and restarting the scheduler should clear any previous state.
What happens if we reenable the feature if it was previously rolled back?
This feature only maintains in-memory, in-flight state, so changing the feature gate, which restarts the scheduler, should not cause issues with a running system.
Are there any tests for feature enablement/disablement?
Not needed, as described above.
Rollout, Upgrade and Rollback Planning
How can a rollout or rollback fail? Can it impact already running workloads?
Rollout can fail if the feature is faulty, causing pods to either not schedule or schedule incorrectly. Already-running pods are not affected; the feature only influences placement decisions for pending pods.
What specific metrics should inform a rollback?
scheduler_pod_scheduling_sli_duration_seconds- rising p99 indicates a scheduling regression.scheduler_schedule_attempts_total(unschedulable or error) - unexpected increase.scheduler_batch_attempts_total- confirms batching is being triggered; drop suggests pods are no longer signable.scheduler_batch_cache_flushed_total- unexpected spikes indicate the cache is being invalidated more than expected.
Were upgrade and rollback tested? Was the upgrade->downgrade->upgrade path tested?
Upgrade and downgrade should be simple due the feature being in-memory. But we will test the path before GA.
Is the rollout accompanied by any deprecations and/or removals of features, APIs, fields of API types, flags, etc.?
No.
Monitoring Requirements
How can an operator determine if the feature is in use by workloads?
Query scheduler_batch_attempts_total - a non-zero value confirms that batching is active for at
least some pods.
How can someone using this feature know that it is working for their instance?
Operators can observe scheduler_batch_attempts_total increasing as batches are processed, and
scheduler_pod_scheduling_sli_duration_seconds improving for batch workloads. End users cannot
directly observe the feature; its effect is transparent, their pods are scheduled faster.
What are the reasonable SLOs (Service Level Objectives) for the enhancement?
The existing scheduler SLOs apply. The feature should not increase scheduling latency for signable workloads; it should decrease it.
What are the SLIs (Service Level Indicators) an operator can use to determine the health of the service?
- Metrics
scheduler_pod_scheduling_sli_duration_seconds- overall scheduling latencyscheduler_batch_attempts_total- counts of batching attempt resultsscheduler_batch_cache_flushed_total- cache invalidation rate and reasonsscheduler_get_node_hint_duration_seconds- hint path latencyscheduler_store_schedule_results_duration_seconds- result storage latency
Are there any missing metrics that would be useful to have to improve observability of this feature?
We will add new metrics to identify behavior. This includes:
- Batched vs non-batched pods - Identify how often a pod can be batched vs not.
- Counts of non-batching reasons - This can include plugin signatures, feasibility checks, etc.
Dependencies
Does this feature depend on any specific services running in the cluster?
No.
Scalability
Will enabling / using this feature result in any new API calls?
No.
Will enabling / using this feature result in introducing new API types?
No.
Will enabling / using this feature result in any new calls to the cloud provider?
No.
Will enabling / using this feature result in increasing size or count of the existing API objects?
No.
Will enabling / using this feature result in increasing time taken by any operations covered by existing SLIs/SLOs?
No, it should result in decreased time for scheduling operations.
Will enabling / using this feature result in non-negligible increase of resource usage (CPU, RAM, disk, IO, …) in any components?
No, it should not.
Can enabling / using this feature result in resource exhaustion of some node resources (PIDs, sockets, inodes, etc.)?
No, it should not.
Troubleshooting
How does this feature react if the API server and/or etcd is unavailable?
The same as the scheduler does today.
What are other known failure modes?
[Increased pod scheduling latencies]
- Detection:
scheduler_pod_scheduling_sli_duration_secondsrises after enabling the feature. - Mitigations: disable
OpportunisticBatchingto restore full-pipeline behavior. - Diagnostics: check
scheduler_batch_cache_flushed_totalfor unexpected invalidation spikes. - Testing: integration perf tests establish expected latency bounds.
- Detection:
[Pods scheduled on incorrect nodes]
- Detection: pods on nodes where they should not be (affinity violations, resource overcommit).
- Mitigations: disable
OpportunisticBatching. - Diagnostics: check batching metrics and scheduler logs.
- Testing: correctness integration tests run scheduling with batching enabled and disabled and compare placements.
What steps should be taken if SLOs are not being met to determine the problem?
- Check
scheduler_batch_attempts_total- a drop or zero value indicates pods are not being batched. Verify signatures are being generated and the gate is enabled. - Check
scheduler_batch_cache_flushed_total- high flush rates indicate the cache is being invalidated frequently. Examine the flush reason labels.
Implementation History
- 2024-05-22: Initial KEP proposal introduced as Enhancements PR #5599 .
- 2025-11-13: Initial version implemented Kubernetes PR #135231 .
- 2026-02-04: Optimized the implementation by caching the pod signature, thereby removing the computation from the critical scheduling path (Kubernetes PR #136579 ).
Drawbacks
Alternatives
Comparison with Equivalence Cache (circa 2018)
This KEP is addressing a very similar problem to the Equivalence Cache (eCache), an approach suggested in 2018 and then retracted because it became extremely complex. While this KEP addresses a similar problem it does so in a very different way, which we believe avoids the issues experienced by the eCache
The issues experienced by eCache were:
- eCache performance was still O(num nodes).
- eCache was complex
- eCache was tightly coupled with plugins.
We’ll address each in turn, but at a high level the differences stem from our scope reduction in this cache, where we focus on simple constraints in a one-pod-per-node world, and are comfortable extending our “race” period slightly.
eCache performance was still O(num nodes)
The eCache was caching a fundamentally different result than this cache. In the case of the eCache they were caching the results of a predicate p, (which is sounds like was one of a number of ops for a given plugin) for a specific pod and node. This meant the number of cache lookups per pod was O(num nodes * num predicates) where num predicates was O(num plugins). Because the cache was so fine-grained, the cache lookups were, in many cases, more expensive than the actual computation. This also meant that while the cache could improve performance, it fundamentally did not remove the O(num nodes) nature of the per pod computation.
In the case of this cache, we are looking up the entire host filtering and scoring for a single pod, so the number of cache lookups per pod is 1. We are caching the entire filtering / scoring result, so the map lookup is guaranteed to be faster even than just iterating over the plugins themselves, let alone the computation needed to filter / score. As the number of nodes go up, the fact that the cache lookup is O(1) per pod will make it an increasingly perfromant alternative to the full computation.
We can cache this more granular data because we only cache for simple plugins, and in fact avoid the complex plugins entirely. Thus we do not need to be concerned about cross pod dependencies, meaning we do not need to try to keep detailed information up-to-date. Because we assume one-pod- per-node and some amount of “staleness” we simply need to invalidate whole hosts, rather than requiring upkeep of complex predicate results required to keep the eCache functional.
eCache was complex
Because the eCache cached predicates, the logic for computing these results went into the cache as well. This meant that significant amount of the plugin functionality was replicated in the cache layer. This added significant complexity to the cache, and also made keeping the cache results themselves up to date complex, involving multiple pods, etc. Because the eCache only improved performance for complex queries, it needed to include this complexity to provide value.
In contrast, the signature used in this cache is just a subset of the pod object, without complex logic. It is static and as the pod object changes slowly, it will change slowly as well. In addition, we explicitly avoid all the complex plugins in this cache because they are rarely used. Thus we do not have the same complexity needed in the cache.
eCache was tightly coupled with plugins
Because a significant amount of the plugin complexity made into the eCache, it was difficult for plugin owners to keep the things in sync. Since in this cache the signature is just parts of the pod object, and the pod object is fairly stable, this makes keeping the signature up to date a much simpler task. The creation of the signature is also spread across the plugins themselves, so instead of needing to keep the cache up to date, plugin owners simply have a new function they need to manage within their plugin, which the cache only aggregates.
We will also provide tests that evaluate different pod configurations against different node configurations and ensure that any time the signatures match the results do as well. This will help us catch issues in the future, in addition to providing testing opportunities in other areas.
See https://github.com/kubernetes/kubernetes/pull/65714#issuecomment-410016382 as starting point on eCache.
Future work
- Group-aware plugin signability: Pods that use
PodTopologySpread,InterPodAffinity, or other group-aware scoring plugins are currently unsignable and receive no batching benefit. Extending the signature mechanism to handle these plugins, possibly by incorporating their constraints into the signature itself, would unlock batching for additional workloads.