← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] ~cjwatson/launchpad:builder-gpu into launchpad:master

 

Colin Watson has proposed merging ~cjwatson/launchpad:builder-gpu into launchpad:master.

Commit message:
Allow partitioning build farm by GPU support

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~cjwatson/launchpad/+git/launchpad/+merge/433195

This allows us to mark certain builders as having GPU support, and mark certain Git repositories as requiring GPU support for their CI builds.  (If necessary, this could be extended to other build types in future.)

I considered whether we might need to have some more generic structure for matching builds to builders.  We may well need to add some kind of "large" builder class.  However, I think economic concerns will mean that we don't want to add too many different classes of builders anyway, so I don't think we need to put much effort into making this more generic for the moment.

There are a few things left to do, which I'm leaving for future branches: GPU-capable builders and their queue sizes aren't displayed separately, and there's no way to modify `GitRepository.require_gpu`.

DB patch: https://code.launchpad.net/~cjwatson/launchpad/+git/launchpad/+merge/433187
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of ~cjwatson/launchpad:builder-gpu into launchpad:master.
diff --git a/lib/lp/buildmaster/browser/builder.py b/lib/lp/buildmaster/browser/builder.py
index 2c3a768..d8aca50 100644
--- a/lib/lp/buildmaster/browser/builder.py
+++ b/lib/lp/buildmaster/browser/builder.py
@@ -194,6 +194,7 @@ class BuilderSetView(CleanInfoMixin, LaunchpadView):
     def getBuilderSortKey(builder):
         return (
             not builder.virtualized,
+            builder.gpu,
             tuple(p.name for p in builder.processors),
             builder.name,
         )
@@ -236,6 +237,8 @@ class BuilderSetView(CleanInfoMixin, LaunchpadView):
         builderset = getUtility(IBuilderSet)
         return builderset.getBuildQueueSizes()
 
+    # XXX cjwatson 2022-11-16: We should probably put GPU-capable builders
+    # in a separate section, once we work out how best to represent that.
     @property
     def virt_builders(self):
         """Return a BuilderCategory object for virtual builders."""
@@ -256,7 +259,11 @@ class BuilderSetView(CleanInfoMixin, LaunchpadView):
 
 
 class BuilderClump:
-    """A "clump" of builders with the same virtualization and processors.
+    """A "clump" of builders with the same principal properties.
+
+    The properties we care about here are whether the builder is
+    virtualized, whether it has GPU support, and which processors it
+    supports.
 
     The name came in desperation from a thesaurus; BuilderGroup and
     BuilderCategory are already in use here for slightly different kinds of
@@ -265,6 +272,7 @@ class BuilderClump:
 
     def __init__(self, builders):
         self.virtualized = builders[0].virtualized
+        self.gpu = builders[0].gpu
         self.processors = builders[0].processors
         self.builders = builders
 
@@ -351,6 +359,18 @@ class BuilderView(CleanInfoMixin, LaunchpadView):
         return english_list(p.name for p in self.context.processors)
 
     @property
+    def extra_properties_text(self):
+        extras = []
+        if self.context.virtualized:
+            extras.append("virtual")
+        if self.context.gpu:
+            extras.append("GPU")
+        if extras:
+            return "(%s)" % ", ".join(extras)
+        else:
+            return ""
+
+    @property
     def current_build_duration(self):
         if self.context.currentjob is None:
             return None
@@ -407,6 +427,7 @@ class BuilderSetAddView(LaunchpadFormView):
         "url",
         "active",
         "virtualized",
+        "gpu",
         "vm_host",
         "vm_reset_protocol",
         "owner",
@@ -427,6 +448,7 @@ class BuilderSetAddView(LaunchpadFormView):
             owner=data.get("owner"),
             active=data.get("active"),
             virtualized=data.get("virtualized"),
+            gpu=data.get("gpu"),
             vm_host=data.get("vm_host"),
             vm_reset_protocol=data.get("vm_reset_protocol"),
         )
@@ -457,6 +479,7 @@ class BuilderEditView(LaunchpadEditFormView):
         "manual",
         "owner",
         "virtualized",
+        "gpu",
         "builderok",
         "failnotes",
         "vm_host",
diff --git a/lib/lp/buildmaster/interactor.py b/lib/lp/buildmaster/interactor.py
index cd46eb4..fe5ea0e 100644
--- a/lib/lp/buildmaster/interactor.py
+++ b/lib/lp/buildmaster/interactor.py
@@ -364,6 +364,7 @@ BuilderVitals = namedtuple(
         "url",
         "processor_names",
         "virtualized",
+        "gpu",
         "vm_host",
         "vm_reset_protocol",
         "builderok",
@@ -387,6 +388,7 @@ def extract_vitals_from_db(builder, build_queue=_BQ_UNSPECIFIED):
         builder.url,
         [processor.name for processor in builder.processors],
         builder.virtualized,
+        builder.gpu,
         builder.vm_host,
         builder.vm_reset_protocol,
         builder.builderok,
diff --git a/lib/lp/buildmaster/interfaces/builder.py b/lib/lp/buildmaster/interfaces/builder.py
index 75cfcfd..60c1073 100644
--- a/lib/lp/buildmaster/interfaces/builder.py
+++ b/lib/lp/buildmaster/interfaces/builder.py
@@ -195,6 +195,15 @@ class IBuilderView(IHasBuildRecords, IHasOwner):
         )
     )
 
+    gpu = exported(
+        Bool(
+            title=_("Supports GPU"),
+            required=True,
+            default=False,
+            description=_("Whether the builder has GPU support."),
+        )
+    )
+
     vm_host = exported(
         TextLine(
             title=_("VM host"),
@@ -306,13 +315,14 @@ class IBuilder(IBuilderEdit, IBuilderView, IBuilderModerateAttributes):
     machine is accessed through an XML-RPC interface; name, title for entity
     identification and browsing purposes; an LP-like owner which has
     unrestricted access to the instance; the machine status
-    representation, including the field/properties: virtualized, builderok,
-    status, failnotes and currentjob.
+    representation, including the field/properties: virtualized, gpu,
+    builderok, status, failnotes and currentjob.
     """
 
 
 class IBuilderSetAdmin(Interface):
     @call_with(owner=REQUEST_USER)
+    @operation_parameters(gpu=copy_field(IBuilderView["gpu"], required=False))
     @export_factory_operation(
         IBuilder,
         [
@@ -334,6 +344,7 @@ class IBuilderSetAdmin(Interface):
         owner,
         active=True,
         virtualized=False,
+        gpu=False,
         vm_host=None,
     ):
         """Create a new builder.
@@ -410,9 +421,10 @@ class IBuilderSet(IBuilderSetAdmin):
             title=_("Processor"), required=True, schema=IProcessor
         ),
         virtualized=Bool(title=_("Virtualized"), required=False, default=True),
+        gpu=Bool(title=_("Supports GPU"), required=False, default=False),
     )
     @operation_returns_collection_of(IBuilder)
     @export_read_operation()
     @operation_for_version("devel")
-    def getBuildersForQueue(processor, virtualized):
-        """Return all builders for given processor/virtualization setting."""
+    def getBuildersForQueue(processor, virtualized, gpu):
+        """Return all builders with the given properties."""
diff --git a/lib/lp/buildmaster/interfaces/buildfarmjob.py b/lib/lp/buildmaster/interfaces/buildfarmjob.py
index f08ae96..5a08b71 100644
--- a/lib/lp/buildmaster/interfaces/buildfarmjob.py
+++ b/lib/lp/buildmaster/interfaces/buildfarmjob.py
@@ -109,6 +109,13 @@ class IBuildFarmJobView(Interface):
         ),
     )
 
+    require_gpu = Bool(
+        title=_("Requires GPU"),
+        required=True,
+        readonly=True,
+        description=_("Whether this build farm job requires GPU support."),
+    )
+
     date_created = exported(
         Datetime(
             title=_("Date created"),
diff --git a/lib/lp/buildmaster/interfaces/buildqueue.py b/lib/lp/buildmaster/interfaces/buildqueue.py
index 2888091..3508905 100644
--- a/lib/lp/buildmaster/interfaces/buildqueue.py
+++ b/lib/lp/buildmaster/interfaces/buildqueue.py
@@ -57,6 +57,10 @@ class IBuildQueue(Interface):
             "The virtualization setting required by this build farm job."
         ),
     )
+    require_gpu = Bool(
+        required=False,
+        description=_("Whether this build farm job requires GPU support."),
+    )
 
     status = Choice(
         title=_("Status"),
@@ -153,10 +157,10 @@ class IBuildQueueSet(Interface):
     def preloadForBuildFarmJobs(builds):
         """Preload buildqueue_record for the given IBuildFarmJobs."""
 
-    def findBuildCandidates(processor, virtualized, limit):
+    def findBuildCandidates(processor, virtualized, gpu, limit):
         """Find candidate jobs for dispatch to idle builders.
 
         :return: A sequence of up to `limit` `IBuildQueue` items with the
             highest score that are for the given `processor` and that match
-            the given value of `virtualized`.
+            the given value of `virtualized` and `gpu`.
         """
diff --git a/lib/lp/buildmaster/manager.py b/lib/lp/buildmaster/manager.py
index 13eb590..23432d4 100644
--- a/lib/lp/buildmaster/manager.py
+++ b/lib/lp/buildmaster/manager.py
@@ -82,7 +82,7 @@ class PrefetchedBuildCandidates:
     @staticmethod
     def _getBuilderGroupKeys(vitals):
         return [
-            (processor_name, vitals.virtualized)
+            (processor_name, vitals.virtualized, vitals.gpu)
             for processor_name in vitals.processor_names + [None]
         ]
 
@@ -111,17 +111,18 @@ class PrefetchedBuildCandidates:
                 if processor_name is not None
                 else None
             )
-            for processor_name, _ in missing_builder_group_keys
+            for processor_name, _, _ in missing_builder_group_keys
         }
         bq_set = getUtility(IBuildQueueSet)
         for builder_group_key in missing_builder_group_keys:
-            processor_name, virtualized = builder_group_key
+            processor_name, virtualized, gpu = builder_group_key
             self._addCandidates(
                 builder_group_key,
                 bq_set.findBuildCandidates(
-                    processors_by_name[processor_name],
-                    virtualized,
-                    len(self.builder_groups[builder_group_key]),
+                    processor=processors_by_name[processor_name],
+                    virtualized=virtualized,
+                    gpu=gpu,
+                    limit=len(self.builder_groups[builder_group_key]),
                 ),
             )
 
diff --git a/lib/lp/buildmaster/model/builder.py b/lib/lp/buildmaster/model/builder.py
index 8d8937f..17ddea5 100644
--- a/lib/lp/buildmaster/model/builder.py
+++ b/lib/lp/buildmaster/model/builder.py
@@ -58,6 +58,7 @@ class Builder(StormBase):
     _builderok = Bool(name="builderok", allow_none=False)
     failnotes = Unicode(name="failnotes")
     virtualized = Bool(name="virtualized", default=True, allow_none=False)
+    gpu = Bool(name="gpu", default=False, allow_none=False)
     manual = Bool(name="manual", default=False)
     vm_host = Unicode(name="vm_host")
     active = Bool(name="active", allow_none=False, default=True)
@@ -82,6 +83,7 @@ class Builder(StormBase):
         vm_reset_protocol=None,
         builderok=True,
         manual=False,
+        gpu=False,
     ):
         super().__init__()
         # The processors cache starts out empty so that the processors
@@ -97,6 +99,7 @@ class Builder(StormBase):
         self.vm_reset_protocol = vm_reset_protocol
         self._builderok = builderok
         self.manual = manual
+        self.gpu = gpu
         # We have to add the new object to the store here (it might more
         # normally be done in BuilderSet.new), because the processors
         # property setter needs to link other objects to it.
@@ -264,6 +267,7 @@ class BuilderSet:
         vm_host=None,
         vm_reset_protocol=None,
         manual=True,
+        gpu=False,
     ):
         """See IBuilderSet."""
         return Builder(
@@ -278,6 +282,7 @@ class BuilderSet:
             vm_reset_protocol=vm_reset_protocol,
             builderok=True,
             manual=manual,
+            gpu=gpu,
         )
 
     def get(self, builder_id):
@@ -320,7 +325,7 @@ class BuilderSet:
         rs = (
             IStore(Builder)
             .find(Builder, Builder.active == True)
-            .order_by(Builder.virtualized, Builder.name)
+            .order_by(Builder.virtualized, Builder.gpu, Builder.name)
         )
 
         def preload(rows):
@@ -333,6 +338,9 @@ class BuilderSet:
 
     def getBuildQueueSizes(self):
         """See `IBuilderSet`."""
+        # XXX cjwatson 2022-11-16: We should probably also expose GPU vs.
+        # non-GPU queue sizes, but we need to work out how best to represent
+        # that.
         results = (
             IStandbyStore(BuildQueue)
             .find(
@@ -358,12 +366,13 @@ class BuilderSet:
 
         return result_dict
 
-    def getBuildersForQueue(self, processor, virtualized):
+    def getBuildersForQueue(self, processor, virtualized, gpu):
         """See `IBuilderSet`."""
         return IStore(Builder).find(
             Builder,
             Builder._builderok == True,
             Builder.virtualized == virtualized,
+            Builder.gpu == gpu,
             BuilderProcessor.builder_id == Builder.id,
             BuilderProcessor.processor == processor,
         )
diff --git a/lib/lp/buildmaster/model/buildfarmjob.py b/lib/lp/buildmaster/model/buildfarmjob.py
index 2a8dc30..41b22f4 100644
--- a/lib/lp/buildmaster/model/buildfarmjob.py
+++ b/lib/lp/buildmaster/model/buildfarmjob.py
@@ -116,6 +116,10 @@ class BuildFarmJob(Storm):
 
 class BuildFarmJobMixin:
     @property
+    def require_gpu(self):
+        return False
+
+    @property
     def dependencies(self):
         return None
 
@@ -277,6 +281,7 @@ class BuildFarmJobMixin:
             build_farm_job=self.build_farm_job,
             processor=self.processor,
             virtualized=self.virtualized,
+            require_gpu=self.require_gpu,
         )
 
         # This build queue job is to be created in a suspended state.
diff --git a/lib/lp/buildmaster/model/buildqueue.py b/lib/lp/buildmaster/model/buildqueue.py
index cc44137..58ad2b8 100644
--- a/lib/lp/buildmaster/model/buildqueue.py
+++ b/lib/lp/buildmaster/model/buildqueue.py
@@ -12,7 +12,7 @@ from itertools import groupby
 from operator import attrgetter
 
 import pytz
-from storm.expr import SQL, And, Desc, Exists, Or
+from storm.expr import SQL, And, Coalesce, Desc, Exists, Or
 from storm.properties import Bool, DateTime, Int, TimeDelta, Unicode
 from storm.references import Reference
 from storm.store import Store
@@ -69,6 +69,7 @@ class BuildQueue(StormBase):
         estimated_duration=DEFAULT,
         virtualized=DEFAULT,
         processor=DEFAULT,
+        require_gpu=DEFAULT,
         lastscore=None,
     ):
         super().__init__()
@@ -76,6 +77,7 @@ class BuildQueue(StormBase):
         self.estimated_duration = estimated_duration
         self.virtualized = virtualized
         self.processor = processor
+        self.require_gpu = require_gpu
         self.lastscore = lastscore
         if lastscore is None and self.specific_build is not None:
             self.score()
@@ -96,6 +98,7 @@ class BuildQueue(StormBase):
     processor_id = Int(name="processor")
     processor = Reference(processor_id, "Processor.id")
     virtualized = Bool(name="virtualized")
+    require_gpu = Bool(name="require_gpu", default=False, allow_none=True)
 
     @property
     def specific_source(self):
@@ -290,7 +293,7 @@ class BuildQueueSet:
         logger = logging.getLogger("worker-scanner")
         return logger
 
-    def findBuildCandidates(self, processor, virtualized, limit):
+    def findBuildCandidates(self, processor, virtualized, gpu, limit):
         """See `IBuildQueueSet`."""
         # Circular import.
         from lp.buildmaster.model.buildfarmjob import BuildFarmJob
@@ -342,6 +345,11 @@ class BuildQueueSet:
                 BuildQueue.status == BuildQueueStatus.WAITING,
                 BuildQueue.processor == processor,
                 BuildQueue.virtualized == virtualized,
+                # Jobs that require a GPU must run on a builder that
+                # supports it.  Jobs that don't require a GPU could in
+                # theory run on a builder that supports GPUs, but at least
+                # for now we prefer to have a hard partition.
+                Coalesce(BuildQueue.require_gpu, False) == gpu,
                 BuildQueue.builder == None,
                 And(*(job_type_conditions + score_conditions))
                 # This must match the ordering used in
diff --git a/lib/lp/buildmaster/stories/builder-views.rst b/lib/lp/buildmaster/stories/builder-views.rst
index fae5d5e..fd0c185 100644
--- a/lib/lp/buildmaster/stories/builder-views.rst
+++ b/lib/lp/buildmaster/stories/builder-views.rst
@@ -77,6 +77,7 @@ by mark. we nee to log in as mark or any other member of admin team
     manual
     owner
     virtualized
+    gpu
     builderok
     failnotes
     vm_host
diff --git a/lib/lp/buildmaster/templates/builder-index.pt b/lib/lp/buildmaster/templates/builder-index.pt
index 2c5172d..0b06bef 100644
--- a/lib/lp/buildmaster/templates/builder-index.pt
+++ b/lib/lp/buildmaster/templates/builder-index.pt
@@ -42,8 +42,7 @@
             <dd>
               <tal:processors repeat="processor context/processors"
                   content="processor/name">386 amd64</tal:processors>
-              <tal:virtual condition="context/virtualized">(virtual)
-              </tal:virtual>
+              <tal:extras content="view/extra_properties_text" />
             </dd>
             <dt>Location:</dt>
             <dd tal:content="context/url" />
diff --git a/lib/lp/buildmaster/tests/mock_workers.py b/lib/lp/buildmaster/tests/mock_workers.py
index 4c56031..002a89d 100644
--- a/lib/lp/buildmaster/tests/mock_workers.py
+++ b/lib/lp/buildmaster/tests/mock_workers.py
@@ -55,6 +55,7 @@ class MockBuilder:
         manual=False,
         processors=None,
         virtualized=True,
+        gpu=False,
         vm_host=None,
         url="http://fake:0000";,
         version=None,
@@ -69,6 +70,7 @@ class MockBuilder:
         self.name = name
         self.processors = processors or []
         self.virtualized = virtualized
+        self.gpu = gpu
         self.vm_host = vm_host
         self.vm_reset_protocol = vm_reset_protocol
         self.failnotes = None
diff --git a/lib/lp/buildmaster/tests/test_builder.py b/lib/lp/buildmaster/tests/test_builder.py
index 77245fe..b892ebe 100644
--- a/lib/lp/buildmaster/tests/test_builder.py
+++ b/lib/lp/buildmaster/tests/test_builder.py
@@ -136,29 +136,97 @@ class TestFindBuildCandidatesGeneralCases(TestFindBuildCandidatesBase):
 
         # No job is returned for a fresh processor.
         proc = self.factory.makeProcessor()
-        self.assertEqual([], self.bq_set.findBuildCandidates(proc, True, 3))
+        self.assertEqual(
+            [],
+            self.bq_set.findBuildCandidates(
+                processor=proc, virtualized=True, gpu=False, limit=3
+            ),
+        )
 
         # bq1 is the best candidate for its processor.
         self.assertEqual(
-            [bq1], self.bq_set.findBuildCandidates(bq1.processor, True, 3)
+            [bq1],
+            self.bq_set.findBuildCandidates(
+                processor=bq1.processor, virtualized=True, gpu=False, limit=3
+            ),
         )
 
         # bq2's score doesn't matter when finding candidates for bq1's
         # processor.
         bq2.manualScore(3000)
-        self.assertEqual([], self.bq_set.findBuildCandidates(proc, True, 3))
         self.assertEqual(
-            [bq1], self.bq_set.findBuildCandidates(bq1.processor, True, 3)
+            [],
+            self.bq_set.findBuildCandidates(
+                processor=proc, virtualized=True, gpu=False, limit=3
+            ),
+        )
+        self.assertEqual(
+            [bq1],
+            self.bq_set.findBuildCandidates(
+                processor=bq1.processor, virtualized=True, gpu=False, limit=3
+            ),
         )
 
         # When looking at bq2's processor, the build with the higher score
         # wins.
         self.assertEqual(
-            [bq2, bq3], self.bq_set.findBuildCandidates(bq2.processor, True, 3)
+            [bq2, bq3],
+            self.bq_set.findBuildCandidates(
+                processor=bq2.processor, virtualized=True, gpu=False, limit=3
+            ),
         )
         bq3.manualScore(4000)
         self.assertEqual(
-            [bq3, bq2], self.bq_set.findBuildCandidates(bq2.processor, True, 3)
+            [bq3, bq2],
+            self.bq_set.findBuildCandidates(
+                processor=bq2.processor, virtualized=True, gpu=False, limit=3
+            ),
+        )
+
+    def test_findBuildCandidates_honours_virtualized(self):
+        proc = self.factory.makeProcessor(supports_virtualized=True)
+        bq_nonvirt = self.factory.makeBinaryPackageBuild(
+            archive=self.factory.makeArchive(virtualized=False), processor=proc
+        ).queueBuild()
+        bq_virt = self.factory.makeBinaryPackageBuild(
+            archive=self.factory.makeArchive(virtualized=True), processor=proc
+        ).queueBuild()
+
+        self.assertEqual(
+            [bq_nonvirt],
+            self.bq_set.findBuildCandidates(
+                processor=proc, virtualized=False, gpu=False, limit=3
+            ),
+        )
+        self.assertEqual(
+            [bq_virt],
+            self.bq_set.findBuildCandidates(
+                processor=proc, virtualized=True, gpu=False, limit=3
+            ),
+        )
+
+    def test_findBuildCandidates_honours_gpu(self):
+        repository_nongpu = self.factory.makeGitRepository()
+        repository_gpu = self.factory.makeGitRepository(require_gpu=True)
+        das = self.factory.makeDistroArchSeries()
+        bq_nongpu = self.factory.makeCIBuild(
+            git_repository=repository_nongpu, distro_arch_series=das
+        ).queueBuild()
+        bq_gpu = self.factory.makeCIBuild(
+            git_repository=repository_gpu, distro_arch_series=das
+        ).queueBuild()
+
+        self.assertEqual(
+            [bq_nongpu],
+            self.bq_set.findBuildCandidates(
+                processor=das.processor, virtualized=True, gpu=False, limit=3
+            ),
+        )
+        self.assertEqual(
+            [bq_gpu],
+            self.bq_set.findBuildCandidates(
+                processor=das.processor, virtualized=True, gpu=True, limit=3
+            ),
         )
 
     def test_findBuildCandidates_honours_limit(self):
@@ -173,13 +241,22 @@ class TestFindBuildCandidatesGeneralCases(TestFindBuildCandidatesBase):
         ]
 
         self.assertEqual(
-            bqs[:5], self.bq_set.findBuildCandidates(processor, True, 5)
+            bqs[:5],
+            self.bq_set.findBuildCandidates(
+                processor=processor, virtualized=True, gpu=False, limit=5
+            ),
         )
         self.assertEqual(
-            bqs, self.bq_set.findBuildCandidates(processor, True, 10)
+            bqs,
+            self.bq_set.findBuildCandidates(
+                processor=processor, virtualized=True, gpu=False, limit=10
+            ),
         )
         self.assertEqual(
-            bqs, self.bq_set.findBuildCandidates(processor, True, 11)
+            bqs,
+            self.bq_set.findBuildCandidates(
+                processor=processor, virtualized=True, gpu=False, limit=11
+            ),
         )
 
     def test_findBuildCandidates_honours_minimum_score(self):
@@ -204,10 +281,16 @@ class TestFindBuildCandidatesGeneralCases(TestFindBuildCandidatesBase):
         # By default, each processor has the two builds we just created for
         # it as candidates, with the highest score first.
         self.assertEqual(
-            bqs[0], self.bq_set.findBuildCandidates(processors[0], True, 3)
+            bqs[0],
+            self.bq_set.findBuildCandidates(
+                processor=processors[0], virtualized=True, gpu=False, limit=3
+            ),
         )
         self.assertEqual(
-            bqs[1], self.bq_set.findBuildCandidates(processors[1], True, 3)
+            bqs[1],
+            self.bq_set.findBuildCandidates(
+                processor=processors[1], virtualized=True, gpu=False, limit=3
+            ),
         )
 
         # If we set a minimum score, then only builds above that threshold
@@ -215,11 +298,21 @@ class TestFindBuildCandidatesGeneralCases(TestFindBuildCandidatesBase):
         with FeatureFixture({"buildmaster.minimum_score": "100000"}):
             self.assertEqual(
                 [bqs[0][0]],
-                self.bq_set.findBuildCandidates(processors[0], True, 3),
+                self.bq_set.findBuildCandidates(
+                    processor=processors[0],
+                    virtualized=True,
+                    gpu=False,
+                    limit=3,
+                ),
             )
             self.assertEqual(
                 [bqs[1][0]],
-                self.bq_set.findBuildCandidates(processors[1], True, 3),
+                self.bq_set.findBuildCandidates(
+                    processor=processors[1],
+                    virtualized=True,
+                    gpu=False,
+                    limit=3,
+                ),
             )
 
         # We can similarly set a minimum score for individual processors.
@@ -240,7 +333,12 @@ class TestFindBuildCandidatesGeneralCases(TestFindBuildCandidatesBase):
                 for i, processor in enumerate(processors):
                     self.assertEqual(
                         expected_bqs[i],
-                        self.bq_set.findBuildCandidates(processor, True, 3),
+                        self.bq_set.findBuildCandidates(
+                            processor=processor,
+                            virtualized=True,
+                            gpu=False,
+                            limit=3,
+                        ),
                     )
 
         # If we set an invalid minimum score, buildd-manager doesn't
@@ -249,11 +347,21 @@ class TestFindBuildCandidatesGeneralCases(TestFindBuildCandidatesBase):
             with FeatureFixture({"buildmaster.minimum_score": "nonsense"}):
                 self.assertEqual(
                     bqs[0],
-                    self.bq_set.findBuildCandidates(processors[0], True, 3),
+                    self.bq_set.findBuildCandidates(
+                        processor=processors[0],
+                        virtualized=True,
+                        gpu=False,
+                        limit=3,
+                    ),
                 )
                 self.assertEqual(
                     bqs[1],
-                    self.bq_set.findBuildCandidates(processors[1], True, 3),
+                    self.bq_set.findBuildCandidates(
+                        processor=processors[1],
+                        virtualized=True,
+                        gpu=False,
+                        limit=3,
+                    ),
                 )
             self.assertEqual(
                 "invalid buildmaster.minimum_score: nonsense\n"
@@ -354,7 +462,9 @@ class TestFindBuildCandidatesPPABase(TestFindBuildCandidatesBase):
 class TestFindBuildCandidatesPPA(TestFindBuildCandidatesPPABase):
     def test_findBuildCandidate(self):
         # joe's fourth i386 build will be the next build candidate.
-        [next_job] = self.bq_set.findBuildCandidates(self.proc_386, True, 1)
+        [next_job] = self.bq_set.findBuildCandidates(
+            processor=self.proc_386, virtualized=True, gpu=False, limit=1
+        )
         build = getUtility(IBinaryPackageBuildSet).getByQueueEntry(next_job)
         self.assertEqual("joesppa", build.archive.name)
 
@@ -362,13 +472,15 @@ class TestFindBuildCandidatesPPA(TestFindBuildCandidatesPPABase):
         # Disabled archives should not be considered for dispatching
         # builds.
         [disabled_job] = self.bq_set.findBuildCandidates(
-            self.proc_386, True, 1
+            processor=self.proc_386, virtualized=True, gpu=False, limit=1
         )
         build = getUtility(IBinaryPackageBuildSet).getByQueueEntry(
             disabled_job
         )
         build.archive.disable()
-        [next_job] = self.bq_set.findBuildCandidates(self.proc_386, True, 1)
+        [next_job] = self.bq_set.findBuildCandidates(
+            processor=self.proc_386, virtualized=True, gpu=False, limit=1
+        )
         self.assertNotEqual(disabled_job, next_job)
 
 
@@ -378,7 +490,9 @@ class TestFindBuildCandidatesPrivatePPA(TestFindBuildCandidatesPPABase):
 
     def test_findBuildCandidate_for_private_ppa(self):
         # joe's fourth i386 build will be the next build candidate.
-        [next_job] = self.bq_set.findBuildCandidates(self.proc_386, True, 1)
+        [next_job] = self.bq_set.findBuildCandidates(
+            processor=self.proc_386, virtualized=True, gpu=False, limit=1
+        )
         build = getUtility(IBinaryPackageBuildSet).getByQueueEntry(next_job)
         self.assertEqual("joesppa", build.archive.name)
 
@@ -387,7 +501,9 @@ class TestFindBuildCandidatesPrivatePPA(TestFindBuildCandidatesPPABase):
         # the source files from the librarian.
         pub = build.current_source_publication
         pub.status = PackagePublishingStatus.PENDING
-        [candidate] = self.bq_set.findBuildCandidates(self.proc_386, True, 1)
+        [candidate] = self.bq_set.findBuildCandidates(
+            processor=self.proc_386, virtualized=True, gpu=False, limit=1
+        )
         self.assertEqual(next_job.id, candidate.id)
 
 
@@ -420,7 +536,9 @@ class TestFindBuildCandidatesDistroArchive(TestFindBuildCandidatesBase):
                 self.gedit_build.buildqueue_record,
                 self.firefox_build.buildqueue_record,
             ],
-            self.bq_set.findBuildCandidates(self.proc_386, True, 3),
+            self.bq_set.findBuildCandidates(
+                processor=self.proc_386, virtualized=True, gpu=False, limit=3
+            ),
         )
 
         # Now even if we set the build building, we'll still get the
@@ -430,7 +548,9 @@ class TestFindBuildCandidatesDistroArchive(TestFindBuildCandidatesBase):
         )
         self.assertEqual(
             [self.firefox_build.buildqueue_record],
-            self.bq_set.findBuildCandidates(self.proc_386, True, 3),
+            self.bq_set.findBuildCandidates(
+                processor=self.proc_386, virtualized=True, gpu=False, limit=3
+            ),
         )
 
     def test_findBuildCandidate_for_recipe_build(self):
@@ -456,7 +576,9 @@ class TestFindBuildCandidatesDistroArchive(TestFindBuildCandidatesBase):
                 self.gedit_build.buildqueue_record,
                 self.firefox_build.buildqueue_record,
             ],
-            self.bq_set.findBuildCandidates(self.proc_386, True, 3),
+            self.bq_set.findBuildCandidates(
+                processor=self.proc_386, virtualized=True, gpu=False, limit=3
+            ),
         )
 
 
@@ -497,5 +619,7 @@ class TestFindRecipeBuildCandidates(TestFindBuildCandidatesBase):
         # This test is run in a "recipe builds only" context.
         self.assertEqual(
             [self.bq2, self.bq1],
-            self.bq_set.findBuildCandidates(self.proc_386, True, 2),
+            self.bq_set.findBuildCandidates(
+                processor=self.proc_386, virtualized=True, gpu=False, limit=2
+            ),
         )
diff --git a/lib/lp/buildmaster/tests/test_manager.py b/lib/lp/buildmaster/tests/test_manager.py
index b7d300e..d872632 100644
--- a/lib/lp/buildmaster/tests/test_manager.py
+++ b/lib/lp/buildmaster/tests/test_manager.py
@@ -944,6 +944,37 @@ class TestPrefetchedBuilderFactory(TestCaseWithFactory):
         # by ID.
         self.assertThat(recorder, HasQueryCount(Equals(1)))
 
+    def test_findBuildCandidate_honours_gpu(self):
+        das = self.factory.makeDistroArchSeries()
+        builders = [
+            self.factory.makeBuilder(processors=[das.processor], gpu=gpu)
+            for gpu in (False, True)
+        ]
+        repository_nongpu = self.factory.makeGitRepository()
+        repository_gpu = self.factory.makeGitRepository(require_gpu=True)
+        bq_nongpu = self.factory.makeCIBuild(
+            git_repository=repository_nongpu, distro_arch_series=das
+        ).queueBuild()
+        bq_gpu = self.factory.makeCIBuild(
+            git_repository=repository_gpu, distro_arch_series=das
+        ).queueBuild()
+        transaction.commit()
+        pbf = PrefetchedBuilderFactory()
+        pbf.update()
+
+        self.assertEqual(
+            bq_nongpu, pbf.findBuildCandidate(pbf.getVitals(builders[0].name))
+        )
+        self.assertIsNone(
+            pbf.findBuildCandidate(pbf.getVitals(builders[0].name))
+        )
+        self.assertEqual(
+            bq_gpu, pbf.findBuildCandidate(pbf.getVitals(builders[1].name))
+        )
+        self.assertIsNone(
+            pbf.findBuildCandidate(pbf.getVitals(builders[1].name))
+        )
+
     def test_acquireBuildCandidate_marks_building(self):
         # acquireBuildCandidate calls findBuildCandidate and marks the build
         # as building.
diff --git a/lib/lp/buildmaster/tests/test_webservice.py b/lib/lp/buildmaster/tests/test_webservice.py
index 8b7fd7a..3a57c4b 100644
--- a/lib/lp/buildmaster/tests/test_webservice.py
+++ b/lib/lp/buildmaster/tests/test_webservice.py
@@ -5,7 +5,7 @@
 
 from json import dumps
 
-from testtools.matchers import Equals
+from testtools.matchers import ContainsDict, Equals, Is
 from zope.component import getUtility
 
 from lp.buildmaster.interfaces.builder import IBuilderSet
@@ -102,6 +102,9 @@ class TestBuildersCollection(TestCaseWithFactory):
         self.factory.makeBuilder(
             processors=[g1], name="g1_builder", virtualized=False
         )
+        self.factory.makeBuilder(
+            processors=[quantum], name="quantum_builder_gpu1", gpu=True
+        )
 
         logout()
         results = self.webservice.named_get(
@@ -116,6 +119,19 @@ class TestBuildersCollection(TestCaseWithFactory):
             sorted(builder["name"] for builder in results["entries"]),
         )
 
+        results = self.webservice.named_get(
+            "/builders",
+            "getBuildersForQueue",
+            processor=api_url(quantum),
+            virtualized=True,
+            gpu=True,
+            api_version="devel",
+        ).jsonBody()
+        self.assertEqual(
+            ["quantum_builder_gpu1"],
+            sorted(builder["name"] for builder in results["entries"]),
+        )
+
     def test_new(self):
         person = self.factory.makePerson()
         badmins = getUtility(IPersonSet).getByName("launchpad-buildd-admins")
@@ -139,8 +155,54 @@ class TestBuildersCollection(TestCaseWithFactory):
         response = webservice.named_post("/builders", "new", **args)
         self.assertEqual(201, response.status)
 
-        self.assertEqual(
-            "foobar", webservice.get("/builders/foo").jsonBody()["title"]
+        self.assertThat(
+            webservice.get("/builders/foo").jsonBody(),
+            ContainsDict(
+                {
+                    "name": Equals("foo"),
+                    "title": Equals("foobar"),
+                    "url": Equals("http://foo.buildd:8221/";),
+                    "virtualized": Is(False),
+                    "gpu": Is(False),
+                }
+            ),
+        )
+
+    def test_new_gpu(self):
+        person = self.factory.makePerson()
+        badmins = getUtility(IPersonSet).getByName("launchpad-buildd-admins")
+        webservice = webservice_for_person(
+            person, permission=OAuthPermission.WRITE_PRIVATE
+        )
+        args = dict(
+            name="foo",
+            processors=["/+processors/386"],
+            title="foobar",
+            url="http://foo.buildd:8221/";,
+            virtualized=True,
+            gpu=True,
+            api_version="devel",
+        )
+
+        response = webservice.named_post("/builders", "new", **args)
+        self.assertEqual(401, response.status)
+
+        with admin_logged_in():
+            badmins.addMember(person, badmins)
+        response = webservice.named_post("/builders", "new", **args)
+        self.assertEqual(201, response.status)
+
+        self.assertThat(
+            webservice.get("/builders/foo").jsonBody(),
+            ContainsDict(
+                {
+                    "name": Equals("foo"),
+                    "title": Equals("foobar"),
+                    "url": Equals("http://foo.buildd:8221/";),
+                    "virtualized": Is(True),
+                    "gpu": Is(True),
+                }
+            ),
         )
 
 
diff --git a/lib/lp/code/interfaces/cibuild.py b/lib/lp/code/interfaces/cibuild.py
index 5c7725f..1b020df 100644
--- a/lib/lp/code/interfaces/cibuild.py
+++ b/lib/lp/code/interfaces/cibuild.py
@@ -113,6 +113,13 @@ class ICIBuildView(IPackageBuildView, IPrivacy):
         )
     )
 
+    require_gpu = Bool(
+        title=_("Requires GPU"),
+        required=True,
+        readonly=True,
+        description=_("Whether this CI build requires GPU support."),
+    )
+
     arch_tag = exported(
         TextLine(title=_("Architecture tag"), required=True, readonly=True)
     )
diff --git a/lib/lp/code/interfaces/gitnamespace.py b/lib/lp/code/interfaces/gitnamespace.py
index 1e3301a..24c74c1 100644
--- a/lib/lp/code/interfaces/gitnamespace.py
+++ b/lib/lp/code/interfaces/gitnamespace.py
@@ -46,6 +46,7 @@ class IGitNamespace(Interface):
         async_hosting=False,
         status=None,
         clone_from_repository=None,
+        require_gpu=False,
     ):
         """Create and return an `IGitRepository` in this namespace.
 
diff --git a/lib/lp/code/interfaces/gitrepository.py b/lib/lp/code/interfaces/gitrepository.py
index 4b0521b..ae7c73e 100644
--- a/lib/lp/code/interfaces/gitrepository.py
+++ b/lib/lp/code/interfaces/gitrepository.py
@@ -307,6 +307,15 @@ class IGitRepositoryView(IHasRecipes):
         )
     )
 
+    require_gpu = Bool(
+        title=_("Requires GPU"),
+        required=True,
+        readonly=True,
+        description=_(
+            "Whether builds of this repository require GPU support."
+        ),
+    )
+
     def getClonedFrom():
         """Returns from which repository the given repo is a clone from."""
 
diff --git a/lib/lp/code/model/cibuild.py b/lib/lp/code/model/cibuild.py
index 0629fd7..934b176 100644
--- a/lib/lp/code/model/cibuild.py
+++ b/lib/lp/code/model/cibuild.py
@@ -183,6 +183,8 @@ class CIBuild(PackageBuildMixin, StormBase):
 
     virtualized = Bool(name="virtualized", allow_none=False)
 
+    _require_gpu = Bool(name="require_gpu", allow_none=True)
+
     date_created = DateTime(
         name="date_created", tzinfo=pytz.UTC, allow_none=False
     )
@@ -224,6 +226,7 @@ class CIBuild(PackageBuildMixin, StormBase):
         distro_arch_series,
         processor,
         virtualized,
+        require_gpu,
         stages,
         date_created=DEFAULT,
     ):
@@ -235,6 +238,7 @@ class CIBuild(PackageBuildMixin, StormBase):
         self.distro_arch_series = distro_arch_series
         self.processor = processor
         self.virtualized = virtualized
+        self._require_gpu = require_gpu
         self._jobs = {"stages": stages}
         self.date_created = date_created
         self.status = BuildStatus.NEEDSBUILD
@@ -282,6 +286,12 @@ class CIBuild(PackageBuildMixin, StormBase):
         """See `ICIBuild`."""
         return self.distro_arch_series.architecturetag
 
+    # XXX cjwatson 2022-11-16: Drop this after backfilling.
+    @property
+    def require_gpu(self):
+        """See `ICIBuild`."""
+        return self._require_gpu or False
+
     @property
     def archive(self):
         """See `IPackageBuild`."""
@@ -591,6 +601,7 @@ class CIBuildSet(SpecificBuildFarmJobSourceMixin):
             distro_arch_series,
             distro_arch_series.processor,
             virtualized=True,
+            require_gpu=git_repository.require_gpu or False,
             stages=stages,
             date_created=date_created,
         )
diff --git a/lib/lp/code/model/gitnamespace.py b/lib/lp/code/model/gitnamespace.py
index 6d52e60..d47a5e5 100644
--- a/lib/lp/code/model/gitnamespace.py
+++ b/lib/lp/code/model/gitnamespace.py
@@ -87,6 +87,7 @@ class _BaseGitNamespace:
         async_hosting=False,
         status=GitRepositoryStatus.AVAILABLE,
         clone_from_repository=None,
+        require_gpu=False,
     ):
         """See `IGitNamespace`."""
         repository_set = getUtility(IGitRepositorySet)
@@ -110,6 +111,7 @@ class _BaseGitNamespace:
             reviewer=reviewer,
             description=description,
             status=status,
+            require_gpu=require_gpu,
         )
         repository._reconcileAccess()
 
diff --git a/lib/lp/code/model/gitrepository.py b/lib/lp/code/model/gitrepository.py
index 64197b3..47cf448 100644
--- a/lib/lp/code/model/gitrepository.py
+++ b/lib/lp/code/model/gitrepository.py
@@ -323,6 +323,8 @@ class GitRepository(
         name="date_last_scanned", tzinfo=pytz.UTC, allow_none=True
     )
 
+    require_gpu = Bool(name="require_gpu", default=False, allow_none=True)
+
     def __init__(
         self,
         repository_type,
@@ -339,6 +341,7 @@ class GitRepository(
         pack_count=None,
         date_last_scanned=None,
         date_last_repacked=None,
+        require_gpu=False,
     ):
         super().__init__()
         self.repository_type = repository_type
@@ -371,6 +374,7 @@ class GitRepository(
         self.pack_count = pack_count
         self.date_last_repacked = date_last_repacked
         self.date_last_scanned = date_last_scanned
+        self.require_gpu = require_gpu
 
     def _createOnHostingService(
         self, clone_from_repository=None, async_create=False
diff --git a/lib/lp/code/model/tests/test_cibuild.py b/lib/lp/code/model/tests/test_cibuild.py
index 8f555a1..72ed3a4 100644
--- a/lib/lp/code/model/tests/test_cibuild.py
+++ b/lib/lp/code/model/tests/test_cibuild.py
@@ -155,9 +155,20 @@ class TestCIBuild(TestCaseWithFactory):
         )
         self.assertEqual(build, bq.specific_build)
         self.assertEqual(build.virtualized, bq.virtualized)
+        self.assertFalse(bq.require_gpu)
         self.assertIsNotNone(bq.processor)
         self.assertEqual(bq, build.buildqueue_record)
 
+    def test_queueBuild_require_gpu(self):
+        # Builds require a GPU if their repository is configured to have its
+        # builds require a GPU.
+        build = self.factory.makeCIBuild(
+            git_repository=self.factory.makeGitRepository(require_gpu=True)
+        )
+        bq = build.queueBuild()
+        self.assertProvides(bq, IBuildQueue)
+        self.assertTrue(bq.require_gpu)
+
     def test_is_private(self):
         # A CIBuild is private iff its repository is.
         build = self.factory.makeCIBuild()
@@ -661,6 +672,7 @@ class TestCIBuildSet(TestCaseWithFactory):
         ).one()
         self.assertProvides(build_queue, IBuildQueue)
         self.assertTrue(build_queue.virtualized)
+        self.assertFalse(build_queue.require_gpu)
         self.assertEqual(BuildQueueStatus.WAITING, build_queue.status)
 
     def test_requestBuild_score(self):
@@ -728,6 +740,7 @@ class TestCIBuildSet(TestCaseWithFactory):
                 repository, commit_sha1, das, [[("test", 0)]]
             )
             self.assertTrue(build.virtualized)
+            self.assertFalse(build.require_gpu)
 
     def test_requestBuild_nonvirtualized(self):
         # A non-virtualized processor cannot run a CI build.
@@ -748,6 +761,22 @@ class TestCIBuildSet(TestCaseWithFactory):
             [[("test", 0)]],
         )
 
+    def test_requestBuild_require_gpu(self):
+        # New builds require a GPU if their repository is configured to have
+        # its builds require a GPU.
+        repository = self.factory.makeGitRepository(require_gpu=True)
+        commit_sha1 = hashlib.sha1(self.factory.getUniqueBytes()).hexdigest()
+        distro_series = self.factory.makeDistroSeries()
+        das = self.factory.makeBuildableDistroArchSeries(
+            distroseries=distro_series,
+            supports_virtualized=True,
+            supports_nonvirtualized=False,
+        )
+        build = getUtility(ICIBuildSet).requestBuild(
+            repository, commit_sha1, das, [[("test", 0)]]
+        )
+        self.assertTrue(build.require_gpu)
+
     def test_requestBuildsForRefs_triggers_builds(self):
         ubuntu = getUtility(ILaunchpadCelebrities).ubuntu
         series = self.factory.makeDistroSeries(
diff --git a/lib/lp/code/model/tests/test_gitnamespace.py b/lib/lp/code/model/tests/test_gitnamespace.py
index b660905..e083b36 100644
--- a/lib/lp/code/model/tests/test_gitnamespace.py
+++ b/lib/lp/code/model/tests/test_gitnamespace.py
@@ -95,6 +95,7 @@ class NamespaceMixin:
         self.assertEqual(registrant, repository.registrant)
         self.assertEqual(reviewer, repository.reviewer)
         self.assertEqual(description, repository.description)
+        self.assertFalse(repository.require_gpu)
 
     def test_createRepository_subscribes_owner(self):
         owner = self.factory.makeTeam()
@@ -145,6 +146,20 @@ class NamespaceMixin:
             hosting_client.create.call_args_list,
         )
 
+    def test_createRepository_require_gpu(self):
+        # createRepository can be told to create a repository whose builds
+        # require a GPU.
+        namespace = self.getNamespace()
+        repository_name = self.factory.getUniqueUnicode()
+        registrant = removeSecurityProxy(namespace).owner
+        repository = namespace.createRepository(
+            GitRepositoryType.HOSTED,
+            registrant,
+            repository_name,
+            require_gpu=True,
+        )
+        self.assertTrue(repository.require_gpu)
+
     def test_getRepositories_no_repositories(self):
         # getRepositories on an IGitNamespace returns a result set of
         # repositories in that namespace.  If there are no repositories, the
diff --git a/lib/lp/testing/factory.py b/lib/lp/testing/factory.py
index 33191fb..b1fa64c 100644
--- a/lib/lp/testing/factory.py
+++ b/lib/lp/testing/factory.py
@@ -3807,6 +3807,7 @@ class LaunchpadObjectFactory(ObjectFactory):
         vm_host=None,
         vm_reset_protocol=None,
         manual=False,
+        gpu=False,
     ):
         """Make a new builder for i386 virtualized builds by default.
 
@@ -3839,6 +3840,7 @@ class LaunchpadObjectFactory(ObjectFactory):
                 vm_host,
                 manual=manual,
                 vm_reset_protocol=vm_reset_protocol,
+                gpu=gpu,
             )
 
     def makeRecipeText(self, *branches):