Kapitel 4. Die Verwendung des Transpilers
Diese Arbeit wurde mithilfe von KI übersetzt. Wir freuen uns über dein Feedback und deine Kommentare: translation-feedback@oreilly.com
Wir haben die Klasse QuantumCircuit
verwendet, um Quantenprogramme darzustellen. Der Zweck von Quantenprogrammen ist es, sie auf realen Geräten laufen zu lassen und Ergebnisse von ihnen zu erhalten. Bei der Programmierung kümmern wir uns normalerweise nicht um die gerätespezifischen Details und verwenden stattdessen High-Level-Operationen. Die meisten Geräte (und einige Simulatoren) können jedoch nur eine kleine Anzahl von Operationen ausführen und können Multiqubit-Gatter nur zwischen bestimmten Qubits durchführen. Das bedeutet, dass wir unsere Schaltung für das spezifische Gerät, auf dem wir arbeiten, umsetzen müssen.
Bei der Transpilierung werden die Operationen in der Schaltung in die vom Gerät unterstützten Operationen umgewandelt und die Qubits (über Swap-Gates) innerhalb der Schaltung ausgetauscht, um die begrenzte Qubit-Konnektivität zu überwinden. Der Transpiler von Qiskit übernimmt diese Aufgabe sowie einige Optimierungen, um die Anzahl der Gatter in der Schaltung zu reduzieren, wo es möglich ist.
Schnellstart mit Transpile
In diesem Abschnitt zeigen wir dir, wie du den Transpiler benutzt, um deine Schaltung gerätefähig zu machen. Wir geben einen kurzen Überblick über die Logik des Transpilers und darüber, wie wir die besten Ergebnisse mit ihm erzielen können.
Das einzige erforderliche Argument für transpile
ist das QuantumCircuit
, das wir umsetzen wollen. Wenn wir aber wollen, dass transpile
etwas Interessantes tut, müssen wir ihm sagen, was es tun soll. Der einfachste Weg, deine Schaltung auf einem Gerät zum Laufen zu bringen, besteht darin, transpile
das Backend-Objekt zu übergeben und es die benötigten Eigenschaften übernehmen zu lassen. transpile
gibt ein neues QuantumCircuit
Objekt zurück, das mit dem Backend kompatibel ist. Der folgende Codeschnipsel zeigt, wie die einfachste Anwendung des Transpilers aussieht:
from
qiskit
import
transpile
transpiled_circuit
=
transpile
(
circuit
,
backend
)
Hier erstellen wir zum Beispiel ein einfaches QuantumCircuit
mit einem Qubit, einem YGate
und zwei CXGate
s:
from
qiskit
import
QuantumCircuit
qc
=
QuantumCircuit
(
3
)
qc
.
y
(
0
)
for
t
in
range
(
2
):
qc
.
cx
(
0
,
t
+
1
)
qc
.
draw
()
Abbildung 4-1 zeigt die Ausgabe von qc.draw()
.
Im nächsten Codeschnipsel entscheiden wir uns, qc
auf dem Mock-Backend FakeSantiago
laufen zu lassen (ein Mock-Backend enthält die Eigenschaften und Geräuschmodelle eines realen Systems und verwendet AerSimulator
, um dieses System zu simulieren). In der Ausgabe (nach dem Code) können wir sehen, dass FakeSantiago
die Operation YGate
nicht versteht:
from
qiskit.test.mock
import
FakeSantiago
santiago
=
FakeSantiago
()
santiago
.
configuration
()
.
basis_gates
[
'id'
,
'rz'
,
'sx'
,
'x'
,
'cx'
,
'reset'
]
Daher muss qc
vor der Ausführung von transpiliert werden. Im nächsten Codeschnipsel werden wir sehen, was der Transpiler macht, wenn wir ihm qc
geben und ihm sagen, dass er für santiago
transpilieren soll:
t_qc
=
transpile
(
qc
,
santiago
)
t_qc
.
draw
()
Abbildung 4-2 zeigt die Ausgabe von t_qc.draw()
.
In Abbildung 4-2 kannst du sehen, dass der Transpiler Folgendes getan hat:
-
Die (virtuellen) Qubits 0, 1 und 2 in
qc
werden den (physikalischen) Qubits 2, 1 und 0 int_qc
zugeordnet. -
Drei weitere
CXGate
s hinzugefügt, um die (physikalischen) Qubits 0und 1 zu tauschen -
Ersetzte unser
YGate
durch einRZGate
und einXGate
-
Zwei zusätzliche Qubits hinzugefügt (da
santiago
fünf Qubits hat)
Das meiste davon scheint ziemlich vernünftig zu sein, mit Ausnahme der Hinzufügung all dieser CXGate
s. CXGate
s sind in der Regel ziemlich teure Operationen, also wollen wir sie so weit wie möglich vermeiden. Warum also hat der Transpiler das getan? In einigen Quantensystemen, darunter santiago
, können nicht alle Qubits direkt miteinander kommunizieren.
Wir können überprüfen, welche Qubits mit einem anderen sprechen können, indem wir die Kopplungskarte des Systems aufrufen (führe backend.configuration().coupling_map
aus, um diese zu erhalten). Ein kurzer Blick auf die Kopplungskarte von santiago
zeigt uns, dass das physische Qubit 2 nicht mit dem physischen Qubit 0 kommunizieren kann, also müssen wir irgendwo einen Tausch vornehmen.
Hier ist die Ausgabe von santiago.configuration().coupling_map
:
[[
0
,
1
],
[
1
,
0
],
[
1
,
2
],
[
2
,
1
],
[
2
,
3
],
[
3
,
2
],
[
3
,
4
],
[
4
,
3
]]
Wenn wir beim Aufruf von transpile
initial_layout=[1,0,2]
setzen, können wir die Art und Weise, wie qc
auf das Backend abgebildet wird, ändern und unnötige Vertauschungen vermeiden. Hier steht der Index jedes Elements in der Liste für das virtuelle Qubit (in qc
) und der Wert an diesem Index für das physische Qubit. Dieses verbesserte Layout setzt die Vermutung des Transpilers außer Kraft und er muss keine zusätzlichen CXGate
s einfügen. Der folgende Codeschnipsel zeigt dies:
t_qc
=
transpile
(
qc
,
santiago
,
initial_layout
=
[
1
,
0
,
2
])
t_qc
.
draw
()
Abbildung 4-3 zeigt die Ausgabe von t_qc.draw()
im vorangegangenen Codeschnipsel.
Da santiago
nur fünf Qubits hat, war es relativ einfach, ein gutes Layout für diese Schaltung auf diesem Gerät zu finden. Bei größeren Schaltkreis/Geräte-Kombinationen müssen wir dies algorithmisch tun. Eine Möglichkeit ist, optimization_level=2
so einzustellen, dass der Transpiler einen intelligenteren (aber teureren) Algorithmus verwendet, um ein besseres Layout auszuwählen.
Die Funktion transpile
lässt vier mögliche Einstellungen füroptimization_level
zu:
optimization_level=0
-
Der Transpiler tut nur das absolute Minimum, das nötig ist, um die Schaltung im Backend zum Laufen zu bringen. Das anfängliche Layout behält die Indizes der physischen und virtuellen Qubits bei, fügt alle erforderlichen Vertauschungen hinzu und wandelt alle Gatter in Basisgatter um.
optimization_level=1
-
Dies ist der Standardwert. Der Transpiler trifft intelligentere Entscheidungen. Wenn wir zum Beispiel weniger virtuelle als physische Qubits haben, wählt der Transpiler die am besten verbundene Teilmenge der physischen Qubits und ordnet die virtuellen Qubits diesen zu. Der Transpiler kombiniert/entfernt auch Sequenzen von Gattern, wo dies möglich ist (z. B. zwei
CXGate
s, die sich gegenseitig aufheben). optimization_level=2
-
Der Transpiler sucht nach einem anfänglichen Layout, das keine Swaps benötigt, um die Schaltung auszuführen, oder, wenn dies fehlschlägt, nach der am besten verbundenen Teilmenge von Qubits. Wie bei Stufe 1 versucht der Transpiler auch hier, Gatter zu kollabieren und aufzuheben, wo es möglich ist.
optimization_level=3
-
Dies ist der höchste Wert, den wir einstellen können. Der Transpiler wird zusätzlich zu den Maßnahmen, die mit
optimization_level=2
ergriffen werden, intelligentere Algorithmen verwenden, um Gates auszulöschen.
Transpiler Pässe
Je nach Anwendungsfall ist der Transpiler oft unsichtbar. Funktionen wie execute
rufen ihn automatisch auf, und dank des Transpilers können wir bei der Erstellung von Schaltkreisen in der Regel das spezifische Gerät ignorieren, an dem wir gerade arbeiten. Trotz dieser Unauffälligkeit kann der Transpiler einen großen Einfluss auf die Leistung eines Schaltkreises haben. In diesem Abschnitt sehen wir uns die Entscheidungen an, die der Transpiler trifft, und erfahren, wie wir sein Verhalten ändern können, wenn es nötig ist.
Der PassManager
Wir bauen eine Transpilationsroutine aus einer Reihe kleinerer "Durchgänge" auf. Jeder Durchlauf ist ein Programm, das eine kleine Aufgabe ausführt (z. B. die Entscheidung über das anfängliche Layout oder das Einfügen von Swap-Gates), und wir verwenden ein PassManager
Objekt, um unsere Abfolge von Durchläufen zu organisieren. In diesem Abschnitt zeigen wir ein einfaches Beispiel mit dem BasicSwap
Durchlauf.
Zuerst brauchen wir einen Quantenschaltkreis zum Umsetzen. Der folgende Codeschnipsel erstellt einen einfachen Schaltkreis, den wir als Beispiel verwenden:
from
qiskit
import
QuantumCircuit
qc
=
QuantumCircuit
(
3
)
qc
.
h
(
0
)
qc
.
cx
(
0
,
2
)
qc
.
cx
(
2
,
1
)
qc
.
draw
()
Abbildung 4-4 zeigt die Ausgabe von qc.draw()
.
Als Nächstes müssen wir die PassManager
und die Pässe, die wir verwenden wollen, importieren und konstruieren. Der Konstruktor von BasicSwap
fragt nach der Kopplungskarte des Geräts, auf dem wir unsere Schaltung ausführen wollen. Im folgenden Codeschnipsel tun wir so, als ob wir die Schaltung auf einem Gerät ausführen wollen, bei dem Qubit 0 nicht mit Qubit 2 interagieren kann (aber Qubit 1 kann mit beiden interagieren). Der Konstruktor PassManager
fragt nach den Übergängen, die wir auf unseren Stromkreis anwenden wollen. In diesem Fall ist das nur der basic_swap
Übergang, den wir in der vorherigen Zeile erstellt haben:
from
qiskit.transpiler
import
PassManager
,
CouplingMap
from
qiskit.transpiler.passes
import
BasicSwap
coupling_map
=
CouplingMap
([[
0
,
1
],
[
1
,
2
]])
basic_swap_pass
=
BasicSwap
(
coupling_map
)
pm
=
PassManager
(
basic_swap_pass
)
Nachdem wir nun unsere Transpilierungsprozedur erstellt haben, können wir sie mit dem folgenden Codeschnipsel auf den Stromkreis anwenden:
routed_qc
=
pm
.
run
(
qc
)
routed_qc
.
draw
()
Abbildung 4-5 zeigt die Ausgabe von routed_qc.draw()
.
In Abbildung 4-5 sehen wir, dass der basic_swap
Durchgang zwei Tauschgatter hinzugefügt hat, um die CXGate
s auszuführen. Beachte jedoch, dass die Qubits nicht in ihrer ursprünglichen Reihenfolge an zurückgegeben wurden.
Kompilieren/Übersetzen von Pässen
Um eine Schaltung auf einem Gerät zum Laufen zu bringen, brauchen wir , um alle Operationen in unserer Schaltung in Anweisungen umzuwandeln, die das Gerät unterstützt. Dazu müssen wir entweder High-Level-Gatter in Low-Level-Gatter zerlegen (eine Form des Kompilierens) oder einen Satz von Low-Level-Gattern in einen anderen übersetzen. Abbildung 4-6 zeigt, wie der Transpiler ein Multicontrolled-X-Gatter in kleinere Gatter zerlegen kann.
Zum Zeitpunkt der Erstellung dieses Artikels gibt es in Qiskit zwei Möglichkeiten, ein Gate in kleinere Gates aufzuteilen. Die erste Möglichkeit ist das definition
Attribut des Gates. Wenn es gesetzt ist, enthält dieses Attribut ein QuantumCircuit
, das dem Gate entspricht. Die Durchläufe Decompose
und Unroller
nutzen diese Definition, um Schaltkreise zu erweitern. Der Decompose
Durchgang erweitert den Schaltkreis nur um eine Ebene, d.h. er versucht nicht, die Definitionen zu zerlegen, durch die wir jedes Tor ersetzt haben. Die Methode .decompose()
der Klasse QuantumCircuit
verwendet den Passus Decompose
. Der Unroller
Pass ist ähnlich, aber er zerlegt die Definitionen der einzelnen Gatter rekursiv, bis der Schaltkreis nur noch die Basisgatter enthält, die wir bei der Konstruktion angegeben haben.
Die zweite Möglichkeit, Gatter zu zerlegen, besteht darin, eine EquivalenceLibrary
zu konsultieren. Diese Bibliothek kann viele Definitionsschaltungen für jede Anweisung speichern, so dass Pässe wählen können, wie sie jede Schaltung zerlegen. Das hat den Vorteil, dass man nicht an einen bestimmten Satz von Basisgattern gebunden ist. Der BasisTranslator
Konstruktor benötigt eine EquivalenceLibrary
und eine Liste von Gatterbezeichnungen. Wenn der Schaltkreis Gatter enthält, die nicht in der Äquivalenzbibliothek enthalten sind, haben wir keine andere Wahl, als die eingebauten Definitionen dieser Gatter zu verwenden. Der UnrollCustomDefinitions
Durchlauf sieht sich die EquivalenceLibrary
an, und wenn jedes Gatter keinen Eintrag in der Bibliothek hat, rollt er dieses Gatter mit seinem .definition
Attribut aus. In den voreingestellten Transpiler-Routinen (die wir später in diesem Kapitel sehen werden), sehen wir normalerweise den UnrollCustomDefinitions
Durchgang direkt vor dem BasisTranslator
Durchgang.
Routing-Pässe
Einige Geräte können Multibit-Gatter nur zwischen bestimmten Teilmengen von Qubits durchführen. Die IBM-Hardware erlaubt in der Regel nur ein Multiqubit-Gatter (das CXGate
) und kann diese Gatter nur zwischen bestimmten Qubit-Paaren ausführen. Wir nennen eine Liste mit jedem Paar möglicher Zwei-Qubit-Wechselwirkungen eine Kopplungskarte. Ein Beispiel dafür haben wir in "Der PassManager" gesehen . In diesem Beispiel haben wir diese Einschränkung überwunden, indem wir die Qubits in der Kopplungskarte mit Hilfe von Swap-Gates verschoben haben. Abbildung 4-7 zeigt ein Beispiel für eine Kopplungskarte.
Qiskit hat einige Algorithmen, um diese Swap Gates hinzuzufügen. Tabelle 4-1 listet alle verfügbaren Swap-Passes mit einer kurzen Beschreibung des Passes auf.
Optimierung Pässe
Der Transpiler fungiert teilweise als Compiler, und wie die meisten Compiler enthält er auch einige Optimierungsläufe. Das größte Problem bei modernen Quantencomputern ist das Rauschen, und der Schwerpunkt dieser Optimierungsläufe liegt darauf, das Rauschen in der Ausgangsschaltung so weit wie möglich zu reduzieren. Die meisten dieser Optimierungsläufe versuchen, das Rauschen und die Laufzeit zu reduzieren, indem sie die Anzahl der Gatter minimieren.
Die einfachsten Optimierungen suchen nach Sequenzen von Gattern, die keine Auswirkungen haben, sodass wir sie sicher entfernen können. Zwei CXGates
hintereinander hätten zum Beispiel keine Auswirkungen auf die unitäre Matrix der Schaltung, daher werden sie mit CXCancellation
entfernt. Ähnlich verhält es sich mit dem Pass RemoveDiagonalGatesBeforeMeasure
, der alle Gatter mit diagonalen Unitarien unmittelbar vor einer Messung entfernt (da sie die Messungen in der Berechnungsgrundlage nicht verändern). Der Durchlauf OptimizeSwapBeforeMeasure
entfernt SWAP-Gatter unmittelbar vor einer Messung und ordnet die Messungen dem klassischen Register zu, um die Ausgangs-Bitfolge zu erhalten.
Qiskit verfügt auch über intelligentere Optimierungsläufe, die versuchen, Gruppen von Gattern durch kleinere oder effizientere Gruppen von Gattern zu ersetzen. Wir können zum Beispiel ganz einfach Sequenzen von Ein-Qubit-Gattern sammeln und sie durch ein einziges UGate
ersetzen, das wir dann wieder in eine effiziente Gruppe von Basisgattern zerlegen können. Die Durchläufe Optimize1qGates
und Optimize1qGatesDecomposition
tun dies für verschiedene Sätze von Ausgangsgattern. Das Gleiche können wir auch für Zwei-Qubit-Gatter tun: Collect2qBlocks
und ConsolidateBlocks
finden Sequenzen von Zwei-Qubit-Gattern und fassen sie zu einer Zwei-Qubit-Unitaritätsmatrix zusammen. Der UnitarySynthesis
-Pass kann diese dann wieder auf die Basisgatter unserer Wahl herunterbrechen.
Abbildung 4-8 zeigt zum Beispiel zwei Schaltungen mit identischen Einheiten, aber unterschiedlicher Anzahl von Gattern.
Anfängliche Layout-Auswahl geht durch
Wie beim Routing müssen wir auch hier entscheiden, wie wir unsere virtuellen Schaltkreis-Qubits den physischen Geräte-Qubits zuordnen. Tabelle 4-2 zeigt einige Algorithmen zur Auswahl des Layouts, die Qiskit anbietet.
Name | Erläuterung |
---|---|
|
In diesem Durchgang werden die Circuit-Qubits einfach über ihre Indizes auf die physikalischen Qubits abgebildet. Zum Beispiel wird das Circuit-Qubit mit dem Index 3 auf das Device-Qubit mit dem Index 3 abgebildet. |
|
Dieser Durchgang findet die am besten verbundene Gruppe von physischen Qubits und ordnet die Schaltkreis-Qubits dieser Gruppe zu. |
|
Dieser Durchlauf verwendet Informationen über die Geräuscheigenschaften des Geräts, um ein Layout auszuwählen. |
|
In diesem Durchgang wird der SABRE-Algorithmus verwendet, um ein Anfangslayout zu finden, das so wenig SWAPs wie möglich erfordert. |
|
Dieser Pass wandelt die Layoutauswahl in ein Constraint Satisfaction Problem (CSP) um. Der Durchlauf verwendet dann die |
Voreingestellte PassManager
Als wir zuvor die High-Level-Funktion transpile
verwendet haben, haben wir uns nicht um die einzelnen Pässe gekümmert und stattdessen den Parameter optimization_level
gesetzt. Dieser Parameter weist den Transpiler an, einen von vier voreingestellten Pass-Managern zu verwenden. Qiskit erstellt diese voreingestellten Passmanager durch Funktionen, die Konfigurationseinstellungen übernehmen und ein PassManager
Objekt zurückgeben. Da wir nun einige Übergänge verstehen, können wir uns ansehen, was die verschiedenen Transpilierungsroutinen tun.
Im Folgenden findest du den Code, mit dem wir die Pässe für eine einfache Transpilationsroutine extrahiert haben, falls du sie nachbauen möchtest:
from
qiskit.transpiler
import
(
PassManagerConfig
,
CouplingMap
)
from
qiskit.transpiler.preset_passmanagers
import
\level_0_pass_manager
from
qiskit.test.mock
import
FakeSantiago
sys_conf
=
FakeSantiago
()
.
configuration
()
pm_conf
=
PassManagerConfig
(
basis_gates
=
sys_conf
.
basis_gates
,
coupling_map
=
CouplingMap
(
sys_conf
.
coupling_map
))
for
i
,
step
in
enumerate
(
level_0_pass_manager
(
pm_conf
)
.
passes
()):
(
f
'Step
{
i
}
:'
)
for
transpiler_pass
in
step
[
'passes'
]:
(
f
'
{
transpiler_pass
.
name
()
}
'
)
Einige der folgenden Durchläufe haben wir in diesem Kapitel nicht behandelt, weil es sich um Analysedurchläufe handelt, die keinen Einfluss auf die Schaltung haben, oder weil es sich um Bereinigungsdurchläufe handelt, für die wir keinen Algorithmus auswählen können. Es ist unwahrscheinlich, dass diese Durchläufe einen vermeidbaren, negativen Einfluss auf die Leistung unserer Schaltungen haben. Außerdem haben wir einige Durchläufe auf Impulsebene nicht behandelt, die nicht in den Rahmen dieses Kapitels fallen:
Step
0
:
SetLayout
Step
1
:
TrivialLayout
Step
2
:
FullAncillaAllocation
EnlargeWithAncilla
ApplyLayout
Step
3
:
Unroll3qOrMore
Step
4
:
CheckMap
Step
5
:
BarrierBeforeFinalMeasurements
StochasticSwap
Step
6
:
UnrollCustomDefinitions
BasisTranslator
Step
7
:
TimeUnitConversion
Step
8
:
ValidatePulseGates
AlignMeasures
Erinnere dich daran, dass optimization_level=0
nur das Nötigste tut, um die Schaltung auf dem Gerät zum Laufen zu bringen. Wir können sehen, dass TrivialLayout
verwendet wird, um ein erstes Layout auszuwählen und dann die Schaltung so zu erweitern, dass sie die gleiche Anzahl von Qubits hat wie das Gerät. Der Transpiler rollt dann die Schaltung zu Ein- und Zwei-Qubit-Gattern aus und verwendet StochasticSwap
für das Routing von . Schließlich rollt er alles so weit wie möglich ab und übersetzt die Schaltung in die Basisgatter des Geräts.
Für optimization_level=3
hingegen enthält die PassManager
die folgenden Durchgänge:
Step
0
:
Unroll3qOrMore
Step
1
:
RemoveResetInZeroState
OptimizeSwapBeforeMeasure
RemoveDiagonalGatesBeforeMeasure
Step
2
:
SetLayout
Step
3
:
TrivialLayout
Layout2qDistance
Step
4
:
CSPLayout
Step
5
:
DenseLayout
Step
6
:
FullAncillaAllocation
EnlargeWithAncilla
ApplyLayout
Step
7
:
CheckMap
Step
8
:
BarrierBeforeFinalMeasurements
StochasticSwap
Step
9
:
UnrollCustomDefinitions
BasisTranslator
Step
10
:
RemoveResetInZeroState
Step
11
:
Depth
FixedPoint
Collect2qBlocks
ConsolidateBlocks
UnitarySynthesis
Optimize1qGatesDecomposition
CommutativeCancellation
UnrollCustomDefinitions
BasisTranslator
Step
12
:
TimeUnitConversion
Step
13
:
ValidatePulseGates
AlignMeasures
Diese PassManager
ist ganz anders. Nach dem Abrollen zu Ein- und Zwei-Qubit-Gattern können wir bereits in Schritt 1 einige Optimierungsläufe sehen, bei denen unnötige Gatter entfernt werden. Der Transpiler probiert dann verschiedene Ansätze zur Layoutauswahl aus. Zuerst prüft er, ob TrivialLayout
optimal ist (d. h., ob keine SWAPs eingefügt werden müssen, um auf dem Gerät ausgeführt zu werden). Wenn das nicht der Fall ist, versucht der Transpiler, mit CSPLayout
ein Layout zu finden. Wenn CSPLayout
keine Lösung findet, verwendet der Transpiler den Algorithmus DenseLayout
. Im nächsten Schritt (Schritt 6) fügt der Transpiler zusätzliche Qubits hinzu (falls nötig), damit die Schaltkreise die gleiche Anzahl an Qubits haben wie das Gerät. Dann verwendet er den StochasticSwap
Algorithmus, um alle Zwei-Qubit-Gatter auf der Kopplungskarte des Geräts zu ermöglichen. Nachdem das Routing erledigt ist, übersetzt der Transpiler den Schaltkreis in die Basisgatter des Geräts, bevor er in Schritt 11 einige letzte Optimierungen vornimmt.
Wenn wir uns die optimization_level=3
Durchläufe ansehen, sehen wir, dass der Transpiler ein sehr anspruchsvolles Programm ist, das einen großen Einfluss auf das Verhalten deiner Schaltungen haben kann. Zum Glück verstehst du jetzt die Probleme , die der Transpiler lösen muss, und einige der Algorithmen, die er dazu verwendet.
Get Qiskit Pocket Guide now with the O’Reilly learning platform.
O’Reilly members experience books, live events, courses curated by job role, and more from O’Reilly and nearly 200 top publishers.