Github Copilot оказался для меня невероятно полезным. Часто он может волшебным образом читать мои мысли и давать полезные рекомендации. Больше всего меня удивила его способность верно «угадывать» функции/переменные по соседнему коду, в том числе и из других файлов. Это может происходить только в том случае, если расширение copilot отправляет ценную информацию из соседнего кода в модель Codex. Мне стало любопытно, как это работает, поэтому я решил изучить исходный код.
В этом посте я попытаюсь ответить на отдельные вопросы по внутренностям Copilot, а также опишу интересные наблюдения, сделанные мной в процессе исследования кода. В большинстве случаев я буду указывать ссылки на соответствующий код, чтобы заинтересовавшиеся читатели могли изучить его самостоятельно.
Обзор реверс-инжиниринга
Пару месяцев назад я провёл довольно поверхностный «реверс-инжиниринг» расширения, но всё равно хотел изучить его подробнее. В последние несколько недель у меня наконец дошли до этого руки. Если не вдаваться в подробности, я взял файл extension.js из состава Copilot, внёс в него небольшие ручные изменения, чтобы упростить автоматическое извлечение модулей, написал несколько преобразований AST, чтобы сделать «красивее» каждый модуль, переименовал и классифицировал модули и вручную аннотировал несколько самых интересных модулей.
Подвергнутую реверс-инжинирингу кодовую базу copilot можно изучить при помощи написанного мной инструмента (домашняя страница, сам инструмент). Возможно, он грубовато спроектирован, но его можно использовать для исследования кода Copilot. В готовом виде он выглядит так. Зайдите на домашнюю страницу, если не знаете, как им пользоваться.
Copilot: общий обзор
Github Copilot состоит из двух основных компонентов:
- Клиент: расширение VSCode собирает всё, что вы вводите (это называется
prompt
), и отправляет это в модель, напоминающую Codex. Всё, что возвращает модель, отображается в вашем редакторе. - Модель: модель, напоминающая Codex, получает prompt и возвращает рекомендации, дополняющие prompt.
Секретный ингредиент 1: проектирование промпта
Codex была обучена на большом объёме публичного кода с Github, поэтому логично, что она может давать полезные рекомендации. Однако Codex не может знать, какие функции существуют в вашем проекте. Несмотря на это, Copilot часто даёт рекомендации, касающиеся функций из вашего проекта. Как же он это делает?
Давайте разобьём ответ на две части: для начала рассмотрим реальный промпт, сгенерированный Copilot, а потом посмотрим, как он генерируется.
Как выглядит промпт?
Расширение кодирует в промпт информацию о вашем проекте. У Copilot имеется довольно изощрённый конвейер проектирования промпта. Вот пример промпта:
{
"prefix": "# Path: codeviz\\app.py\n# Compare this snippet from codeviz\\predictions.py:\n# import json\n# import sys\n# import time\n# from manifest import Manifest\n# \n# sys.path.append(__file__ + \"/..\")\n# from common import module_codes, module_deps, module_categories, data_dir, cur_dir\n# \n# gold_annots = json.loads(open(data_dir / \"gold_annotations.js\").read().replace(\"let gold_annotations = \", \"\"))\n# \n# M = Manifest(\n# client_name = \"openai\",\n# client_connection = open(cur_dir / \".openai-api-key\").read().strip(),\n# cache_name = \"sqlite\",\n# cache_connection = \"codeviz_openai_cache.db\",\n# engine = \"code-davinci-002\",\n# )\n# \n# def predict_with_retries(*args, **kwargs):\n# for _ in range(5):\n# try:\n# return M.run(*args, **kwargs)\n# except Exception as e:\n# if \"too many requests\" in str(e).lower():\n# print(\"Too many requests, waiting 30 seconds...\")\n# time.sleep(30)\n# continue\n# else:\n# raise e\n# raise Exception(\"Too many retries\")\n# \n# def collect_module_prediction_context(module_id):\n# module_exports = module_deps[module_id][\"exports\"]\n# module_exports = [m for m in module_exports if m != \"default\" and \"complex-export\" not in m]\n# if len(module_exports) == 0:\n# module_exports = \"\"\n# else:\n# module_exports = \"It exports the following symbols: \" + \", \".join(module_exports)\n# \n# # get module snippet\n# module_code_snippet = module_codes[module_id]\n# # snip to first 50 lines:\n# module_code_snippet = module_code_snippet.split(\"\\n\")\n# if len(module_code_snippet) > 50:\n# module_code_snippet = \"\\n\".join(module_code_snippet[:50]) + \"\\n...\"\n# else:\n# module_code_snippet = \"\\n\".join(module_code_snippet)\n# \n# return {\"exports\": module_exports, \"snippet\": module_code_snippet}\n# \n# #### Name prediction ####\n# \n# def _get_prompt_for_module_name_prediction(module_id):\n# context = collect_module_prediction_context(module_id)\n# module_exports = context[\"exports\"]\n# module_code_snippet = context[\"snippet\"]\n# \n# prompt = f\"\"\"\\\n# Consider the code snippet of an unmodule named.\n# \nimport json\nfrom flask import Flask, render_template, request, send_from_directory\nfrom common import *\nfrom predictions import predict_snippet_description, predict_module_name\n\napp = Flask(__name__)\n\n@app.route('/')\ndef home():\n return render_template('code-viz.html')\n\n@app.route('/data/<path:filename>')\ndef get_data_files(filename):\n return send_from_directory(data_dir, filename)\n\n@app.route('/api/describe_snippet', methods=['POST'])\ndef describe_snippet():\n module_id = request.json['module_id']\n module_name = request.json['module_name']\n snippet = request.json['snippet']\n description = predict_snippet_description(\n module_id,\n module_name,\n snippet,\n )\n return json.dumps({'description': description})\n\n# predict name of a module given its id\n@app.route('/api/predict_module_name', methods=['POST'])\ndef suggest_module_name():\n module_id = request.json['module_id']\n module_name = predict_module_name(module_id)\n",
"suffix": "if __name__ == '__main__':\r\n app.run(debug=True)",
"isFimEnabled": true,
"promptElementRanges": [
{ "kind": "PathMarker", "start": 0, "end": 23 },
{ "kind": "SimilarFile", "start": 23, "end": 2219 },
{ "kind": "BeforeCursor", "start": 2219, "end": 3142 }
]
}
Как видите, промпт содержит и префикс, и суффикс. Copilot отправляет этот промпт (предварительно отформатировав его) модели. В данном случае Copilot вызывает Codex в «insert mode» (режиме вставки), или в режиме fill-in-middle (FIM), потому что суффикс не пустой.
Если вы изучите префикс (для простоты изучения см. здесь), то увидите, что в него включён код из другого файла проекта. Взгляните на строку
# Compare this snippet from codeviz\\predictions.py:
и последующие строки.Как подготавливается промпт? Обход кода.
Если упростить, для генерации промпта используется следующая последовательность шагов:
- Точка входа: извлечение промпта происходит для конкретного документа и позиции курсора. Основная точка входа генерации промпта это
extractPrompt(ctx, doc, insertPos)
- Из VSCode запрашивается относительный путь к документу и ID языка. См.
getPromptForRegularDoc(ctx, doc, insertPos)
. - Релевантные документы: затем из VSCode запрашиваются 20 последних файлов на том же языке. См.
getPromptHelper(ctx, docText, insertOffset, docRelPath, docUri, docLangId)
. Эти файлы позже используются для извлечения схожих фрагментов кода, которые будут включены в промпт. Лично мне кажется странным, что в качестве фильтра используется язык, потому что достаточно часто применяется многоязыковая разработка. Но, вероятно, это покрывает большинство сценариев. - Конфигурация: далее задаются опции. А именно:
suffixPercent
(часть токенов промпта, которые будут относиться к суффиксу. Похоже, по умолчанию значение равно 15%)fimSuffixLengthThreshold
(минимальная длина суффикса для включения Fill-in-middle. По умолчанию равно -1, поэтому при непустом суффиксе FIM всегда включен, но этим управляет фреймворк AB Experimentation)includeSiblingFunctions
— похоже, по умолчанию жёстко задан равным false, еслиsuffixPercent
выше 0 (что по умолчанию истинно).
- Вычисление префикса: далее для вычисления части промпта, относящейся к префиксу, создаётся «Prompt Wishlist». В неё добавляются различные «элементы» вместе с их приоритетами. Например, элемент может выглядеть примерно как «Compare this snippet from <path>», или как локальный контекст import, или как ID языка и/или как путь к каждому файлу. Это происходит в
getPrompt(fs, curFile, promptOpts = {}, relevantDocs = [])
.
- Существует 6 типов «элементов» –
BeforeCursor
,AfterCursor
,SimilarFile
,ImportedFile
,LanguageMarker
,PathMarker
. - Так как размер промпта ограничен, вишлист сортируется по приоритету и порядку вставки, а затем в промпт начинают добавляться элементы, пока не будет достигнут предельный размер. Эта логика заполнения реализована в
PromptWishlist.fulfill(tokenBudget)
. - Отдельные опции наподобие
LanguageMarkerOption
,NeighboringTabsPositionOption
,SuffixStartMode
и так далее управляют порядком вставки и приоритетами этих элементов. Другие управляют извлечением определённой информации, например,NeighboringTabsOption
управляет тем, насколько агрессивно извлекаются фрагменты кода из других файлов. Некоторые опции задаются только для конкретных языков, например,LocalImportContextOption
определяется только для Typescript. - Любопытно, что достаточно большой объём кода занимается упорядочиванием этих элементов. Не знаю точно, используется ли весь этот код целиком, некоторые части кажутся мне мёртвым кодом, например, похоже, что
neighboringTabsPosition
никогда не принимает значениеDirectlyAboveCursor
… но я могу ошибаться. Аналогично, опцииSiblingOption
, похоже, жёстко задано значениеNoSiblings
, и это означает, что на самом деле никакого извлечения sibling function не происходит. Эта функциональность может быть запланированной на будущее, а может быть просто мёртвым кодом.
- Существует 6 типов «элементов» –
- Вычисление суффикса: предыдущий шаг выполнялся для префикса, однако логика для суффикса относительно проста — всего лишь заполняем имеющийся запас токенов тем суффиксом, который доступен после курсора. Так происходит по умолчанию, однако начальная позиция суффикса может немного меняться в зависимости от опции
SuffixStartMode
. Это контролируется фреймворком AB Experimentation. Например, еслиSuffixStartMode
имеет значениеSiblingBlock
, то Copilot сначала будет искать ближайшую функцию, являющуюся сиблингом редактируемой функции, и начинать суффикс от неё.
- Кэширование суффикса: одно из странных поведений Copilot заключается в кэшировании суффикса между вызовами, если новый суффикс «не слишком далеко» от кэшированного суффикса. Понятия не имею, зачем это делается. Или, возможно, я неправильно понял обфусцированный код (хотя и не смог найти альтернативного объяснения этому коду).
Подробное исследование извлечения фрагментов кода
На мой взгляд, наиболее завершённой частью генерации промпта является извлечение фрагментов кода (snippet extraction) из других файлов. Он вызывается здесь и определяется в neighbor-snippet-selector.getNeighbourSnippets. В зависимости от значений опций он использует или «Fixed window Jaccard matcher», или «Indentation based Jaccard Matcher». Я не уверен на 100%, но похоже, что Indentation based Jaccard Matcher на самом деле не используется.
По умолчанию используется fixed window Jaccard Matcher. Этот класс разделяет переданный файл (из которого будут извлекаться фрагменты кода) на скользящие окна постоянного размера. Затем он вычисляет сходство Жаккарда между каждым окном и справочным файлом (файлом, в который вы выполняете ввод). Для каждого «релевантного файла» возвращается только самое лучшее окно (существует код для возврата лучших K фрагментов, но он никогда не используется). По умолчанию, FixedWindowJaccardMatcher используется в «Eager mode» (то есть с размером окна, равным 60 строкам). Однако выбором режима управляет фреймворк AB Experimentation, поэтому могут использоваться и другие режимы.
Секретный ингредиент 2: вызов модели
Существует два UI, при помощи которых Copilot обеспечивает дополнение кода: (а) Inline/GhostText и (б) Copilot Panel. Есть некоторые различия в вызове модели в этих двух случаях.
Inline/GhostText
Основной модуль
Здесь чтобы работать быстро, расширение запрашивает у модели очень малое количество рекомендаций (1-3). Также оно агрессивно кэширует результаты из модели. Более того, оно занимается адаптацией рекомендаций, если пользователь продолжает печатать. Также оно занимается устранением ложных запросов к модели, если пользователь печатает быстро.
Также в UI есть логика, в некоторых случаях предотвращающая отправку запросов. Например, если пользователь находится посередине строки, то запрос отправляется только тогда, когда символы справа от курсора — это пробелы, закрывающие фигурные скобки и так далее.
Предотвращение плохих запросов при помощи контекстного фильтра
Любопытнее то, что после генерации промпта модуль проверяет, «достаточно ли он хорош», чтобы не заморачиваться с вызовом модели. Это реализовано посредством вычисления так называемого «contextual filter score» (показателя контекстуального фильтра). Похоже, этот показатель основывается на простой модели логистической регрессии по 11 признакам (по языку, по тому, одобрена ли предыдущая рекомендация, по длине последней строки в промпте, по последнему символу после курсора и так далее). Веса модели включены в сам код расширения.
Если показатель ниже порогового значения (по умолчанию это 15%), то запрос не осуществляется. Было бы интересно поэкспериментировать с этой моделью. Я заметил, что некоторые языки имеют больший вес, чем другие (например,
php > js > python > rust > dart
… php
, серьёзно?). Ещё одно интуитивное наблюдение заключалось в том, что если промпт завершается )
или ]
, то показатель бывает ниже (-0.999
, -0.970
), чем если бы он завершался (
или [
(0.932
, 0.049
). Это логично, поскольку первый вариант с большой вероятностью уже «завершён», а последний чётко даёт понять, что пользователю пригодится автоматическое дополнение.Панель Copilot
Основной модуль, базовая логика 1, базовая логика 2.
Этот UI требует от модели больше сэмплов (по умолчанию 10), чем встроенный UI. Похоже, этот UI не имеет логики контекстуального фильтра (это логично: если пользователь вызвал его самостоятельно, то нет смысла отправлять модели промпт).
Здесь есть два интересных аспекта:
- В зависимости от режима, в котором он вызван (
OPEN_COPILOT
/TODO_QUICK_FIX
/UNKNOWN_FUNCTION_QUICK_FIX
), интерфейс незначительно изменяет промпт. Не спрашивайте меня, как активируются эти режимы. - Он запрашивает у модели logprobs, и список решений сортируется по среднему logprobs.
Не показывать бесполезных автодополнений
Перед отображением рекомендации (через один из UI), Copilot выполняет две проверки:
- Если результат повторяющийся (например,
foo = foo = foo = foo...
), что является распространённым состоянием отказа языковых моделей, то рекомендация отвергается. Также это может произойти в прокси-сервере Copilot, в клиенте, или и там, и там. - Если пользователь уже ввёл рекомендованное, то оно отвергается.
Секретный ингредиент 3: телеметрия
Github утверждает, что 40% кода, который пишется программистами, написан Copilot (для популярных языков наподобие Python). Мне стало интересно, как компания получила это число, поэтому я захотел немного изучить код телеметрии.
Также мне было интересно узнать, какие телеметрические данные собираются, и особенно собираются ли фрагменты кода. Я хотел это знать, потому что хотя мы и можем заставить расширение Copilot работать с опенсорсным бэкендом FauxPilot вместо бэкенда Github, расширение всё равно вполне может отправлять фрагменты кода при помощи телеметрии в Github, отталкивая от использования Copilot тех, кто параноидально заботится о конфиденциальности своего кода. Мне хотелось узнать, так ли это.
Вопрос 1: какими замерами получено значение 40%?
Для измерения коэффициента успеха Copilot недостаточно просто тривиально подсчитать количество принятых/отклонённых попыток, потому что люди обычно принимают рекомендации, а затем вносят в них какие-то модификации. Поэтому сотрудники Github проверяют, находится ли по-прежнему в коде принятая рекомендация. Это выполняется через различные временные интервалы после вставки. Спустя таймауты в 15 с, 30 с, 2 мин, 5 мин и 10 мин расширение проверяет, по-прежнему ли находится в коде принятая рекомендация.
Выполнение точного поиска на наличие принятой рекомендации слишком ограничивает, поэтому вместо этого они измеряют редакторское расстояние (на уровне символов и слов) между рекомендованным текстом и окном вокруг точки вставки. Если редакторское расстояние на уровне слов между вставленным
и окном меньше 50% (нормализованное по размеру рекомендации), то считается, что рекомендация по-прежнему находится в коде.
Разумеется, это происходит только с принятым кодом.
Вопрос 2: содержат ли телеметрические данных фрагменты кода?
Да.
Спустя 30 с после принятия/отклонения рекомендации copilot делает снэпшот вокруг точки вставки. В частности, расширение вызывает механизм извлечения промпта для сбора «гипотетического промпта», который можно было бы использовать для создания рекомендации на этом этапе. Также он выполняет захват «гипотетического автодополнения», захватывая код между точкой вставки и «предполагаемой» конечной точкой, то есть точкой, после которой идёт код, не относящийся к автодополнению. Я так и не понял, как он угадывает эту конечную точку. Как говорилось выше, и то, и другое происходит после принятия или отклонения.
Подозреваю, что эти снэпшоты, по сути, используются как обучающие данные для дальнейшего совершенствования модели. Однако 30 секунд кажется слишком мало для того, чтобы предположить, что код уже «стабилизировался». Но я думаю, что даже если таймаут в 30 секунд даёт очень шумные примеры данных, учитывая, что в телеметрию включается репозиторий на Github, соответствующий проекту пользователя, сотрудники Copilot, вероятно, могут очищать эти относительно шумные данные офлайн. Впрочем, всё это лишь мои предположения.
Другие интересности
Я немного модифицировал код расширения, чтобы включить подробное логирование (не смог найти для этого настраиваемый параметр). Выяснилось, что модель называется cushman-ml, и это даёт веские основания предполагать, что Copilot использует модель на 12 миллиардов параметров, а не на 175 миллиардов. Это очень вдохновляет на опенсорсную разработку, показывая, что даже модель средних размеров способна давать такие качественные рекомендации. Но, разумеется, у неё не будет того объёма данных, который есть у Github.
В этом исследовании я не рассматривал поставляемый с расширением файл worker.js. Поначалу кажется, что он обеспечивает распараллеленную версию логики извлечения промптов, однако в нём есть ещё и нечто другое.
Включение подробного логирования
Если вы хотите включить подробное логирование, это можно сделать, изменив код расширения следующим образом:
- Найдите файл расширения. Обычно он находится в
~/.vscode/extensions/github.copilot-<version>/dist/extension.js
. - Найдите строку
shouldLog(e,t,n){
(или, если не найдёте, попробуйтеshouldLog(
). Одно из нескольких совпадений окажется непустым определением функции. - В начало тела функции добавьте
return true
.
Если вам нужен готовый патч, просто скопируйте код расширения. Учтите, что он предназначен для версии 1.57.7193.
Что дальше?
Это был маленький интересный проект, однако он потребовал доли ручного аннотирования/реверс-инжиниринга. Я бы хотел автоматизировать большую его часть, чтобы можно было изучать и другие версии Copilot, или исследовать Copilot labs… или просто в целом выполнять автоматическую декомпиляцию обфусцированного кода на JS. Мои первые эксперименты с ChatGPT/Codex вдохновляли, но проблема в их ненадёжности. У меня есть идея об автоматической проверке правильности декомпиляции выполнением разновидности абстрактной интерпретации. Но это уже работа на будущее.
Ссылки
Код для этого проекта выложен здесь. Можете свободно изучать его, отправлять тикеты и PR, если у вас появятся предложения или улучшения. Например, можете аннотировать другие интересные модули или опубликовать собственные находки!