Skip to content

Commit

Permalink
backend/ninja: use a two step process for dependency scanning
Browse files Browse the repository at this point in the history
This splits the scanner into two discrete steps, one that scans the
source files, and one that that reads in the dependency information and
produces a dyndep.

The scanner uses the JSON format from
https://www.open-std.org/jtc1/sc22/wg21/docs/papers/2022/p1689r5.html,
which is the same format the MSVC and Clang use for C++ modules
scanning. This will allow us to more easily move to using MSVC and
clang-scan-deps when possible.

As an added bonus, this correctly tracks dependencies across TU and
Target boundaries, unlike the previous implementation, which assumed
that if it couldn't find a provider that everything was good, but could
run into issues.

TODO: needs a test to prove that the target thing is true
  • Loading branch information
dcbaker committed Nov 22, 2023
1 parent c2eb14e commit 282c0b1
Show file tree
Hide file tree
Showing 3 changed files with 238 additions and 53 deletions.
38 changes: 29 additions & 9 deletions mesonbuild/backend/ninjabackend.py
Original file line number Diff line number Diff line change
Expand Up @@ -1100,7 +1100,7 @@ def generate_dependency_scan_target(self, target: build.BuildTarget,
object_deps: T.List['mesonlib.FileOrString']) -> None:
if not self.should_use_dyndeps_for_target(target):
return
depscan_file = self.get_dep_scan_file_for(target)
json_file, depscan_file = self.get_dep_scan_file_for(target)
pickle_base = target.name + '.dat'
pickle_file = os.path.join(self.get_target_private_dir(target), pickle_base).replace('\\', '/')
pickle_abs = os.path.join(self.get_target_private_dir_abs(target), pickle_base).replace('\\', '/')
Expand All @@ -1112,24 +1112,32 @@ def generate_dependency_scan_target(self, target: build.BuildTarget,
with open(pickle_abs, 'wb') as p:
pickle.dump(scaninfo, p)

elem = NinjaBuildElement(self.all_outputs, depscan_file, rule_name, pickle_file)
elem = NinjaBuildElement(self.all_outputs, json_file, rule_name, pickle_file)
# Add any generated outputs to the order deps of the scan target, so
# that those sources are present
for g in generated_source_files:
elem.orderdeps.add(g.relative_name())
elem.orderdeps.update(object_deps)
self.add_build(elem)

infiles = [json_file]
for t in itertools.chain(target.link_targets, target.link_whole_targets):
if self.should_use_dyndeps_for_target(t):
infiles.append(self.get_dep_scan_file_for(t)[0])

elem = NinjaBuildElement(self.all_outputs, depscan_file, 'depaccumulate', infiles)
self.add_build(elem)

def select_sources_to_scan(self, compiled_sources: T.List[str]
) -> T.Iterable[T.Tuple[str, Literal['cpp', 'fortran']]]:
# in practice pick up C++ and Fortran files. If some other language
# requires scanning (possibly Java to deal with inner class files)
# then add them here.
for source in compiled_sources:
ext = os.path.splitext(source)[1][1:]
if ext in compilers.lang_suffixes['cpp'] or ext == 'C':
if ext.lower() in compilers.lang_suffixes['cpp'] or ext == 'C':
yield source, 'cpp'
elif ext in compilers.lang_suffixes['fortran']:
elif ext.lower() in compilers.lang_suffixes['fortran']:
yield source, 'fortran'

def process_target_dependencies(self, target):
Expand Down Expand Up @@ -2514,13 +2522,22 @@ def generate_scanner_rules(self):
if rulename in self.ruledict:
# Scanning command is the same for native and cross compilation.
return

command = self.environment.get_build_command() + \
['--internal', 'depscan']
args = ['$picklefile', '$out', '$in']
description = 'Module scanner.'
rule = NinjaRule(rulename, command, args, description)
self.add_rule(rule)

rulename = 'depaccumulate'
command = self.environment.get_build_command() + \
['--internal', 'depaccumulate']
args = ['$out', '$in']
description = 'Module dependency accumulator.'
rule = NinjaRule(rulename, command, args, description)
self.add_rule(rule)

def generate_compile_rules(self):
for for_machine in MachineChoice:
clist = self.environment.coredata.compilers[for_machine]
Expand Down Expand Up @@ -3050,12 +3067,13 @@ def add_dependency_scanner_entries_to_element(self, target: build.BuildTarget, c
extension = extension.lower()
if not (extension in compilers.lang_suffixes['fortran'] or extension in compilers.lang_suffixes['cpp']):
return
dep_scan_file = self.get_dep_scan_file_for(target)
dep_scan_file = self.get_dep_scan_file_for(target)[1]
element.add_item('dyndep', dep_scan_file)
element.add_orderdep(dep_scan_file)

def get_dep_scan_file_for(self, target: build.BuildTarget) -> str:
return os.path.join(self.get_target_private_dir(target), 'depscan.dd')
def get_dep_scan_file_for(self, target: build.BuildTarget) -> T.Tuple[str, str]:
priv = self.get_target_private_dir(target)
return os.path.join(priv, 'depscan.json'), os.path.join(priv, 'depscan.dd')

def add_header_deps(self, target, ninja_element, header_deps):
for d in header_deps:
Expand All @@ -3077,8 +3095,10 @@ def has_dir_part(self, fname: mesonlib.FileOrString) -> bool:
# instead just have an ordered dependency on the library. This ensures all required mod files are created.
# The real deps are then detected via dep file generation from the compiler. This breaks on compilers that
# produce incorrect dep files but such is life.
def get_fortran_orderdeps(self, target, compiler):
if compiler.language != 'fortran':
def get_fortran_orderdeps(self, target: build.BuildTarget, compiler: Compiler) -> T.List[str]:
# If we have dyndeps then we don't need this, since the depscanner will
# do all of things described above.
if compiler.language != 'fortran' or self.use_dyndeps_for_fortran():
return []
return [
os.path.join(self.get_target_dir(lt), lt.get_filename())
Expand Down
130 changes: 130 additions & 0 deletions mesonbuild/scripts/depaccumulate.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
# SPDX-License-Identifier: Apache-2.0
# Copyright © 2021 Intel Corporation

"""Accumulator for p1689r5 module dependencies.
See: https://www.open-std.org/jtc1/sc22/wg21/docs/papers/2022/p1689r5.html
"""

from __future__ import annotations
import json
import re
import textwrap
import typing as T

if T.TYPE_CHECKING:
from .depscan import Description, Rule

# The quoting logic has been copied from the ninjabackend to avoid having to
# import half of Meson just to quote outputs, which is a performance problem
_QUOTE_PAT = re.compile(r"[$ :\n]")


def quote(text: str) -> str:
# Fast path for when no quoting is necessary
if not _QUOTE_PAT.search(text):
return text
if '\n' in text:
errmsg = textwrap.dedent(f'''\
Ninja does not support newlines in rules. The content was:
{text}
Please report this error with a test case to the Meson bug tracker.''')
raise RuntimeError(errmsg)
return _QUOTE_PAT.sub(r'$\g<0>', text)


_PROVIDER_CACHE: T.Dict[str, str] = {}


def get_provider(rules: T.List[Rule], name: str) -> T.Optional[str]:
"""Get the object that a module from another Target provides
We must rely on the object file here instead of the module itself, because
the object rule is part of the generated build.ninja, while the module is
only declared inside a dyndep. This creates for the dyndep generator to
depend on previous dyndeps as order deps. Since the module
interface file will be generated when the object is generated we can rely on
that in proxy and simplify generation.
:param rules: The list of rules to check
:param name: The logical-name to look for
:raises RuntimeError: If no provider can be found
:return: The object file of the rule providing the module
"""
# Cache the result for performance reasons
if name in _PROVIDER_CACHE:
return _PROVIDER_CACHE[name]

for r in rules:
for p in r.get('provides', []):
if p['logical-name'] == name:
obj = r['primary-output']
_PROVIDER_CACHE[name] = obj
return obj
return None


def process_rules(rules: T.List[Rule],
extra_rules: T.List[Rule],
) -> T.Iterable[T.Tuple[str, T.Optional[T.List[str]], T.Optional[T.List[str]]]]:
"""Process the rules for this Target
:param rules: the rules for this target
:param extra_rules: the rules for all of the targets this one links with, to use their provides
:yield: A tuple of the output, the exported modules, and the consumed modules
"""
for rule in rules:
prov: T.Optional[T.List[str]] = None
req: T.Optional[T.List[str]] = None
if 'provides' in rule:
prov = [p['compiled-module-path'] for p in rule['provides']]
if 'requires' in rule:
req = []
for p in rule['requires']:
modfile = p.get('compiled-module-path')
if modfile is not None:
req.append(modfile)
else:
# We can't error if this is not found because of compiler
# provided modules
found = get_provider(extra_rules, p['logical-name'])
if found:
req.append(found)
yield rule['primary-output'], prov, req


def format(files: T.Optional[T.List[str]]) -> str:
if files:
fmt = " ".join(quote(f) for f in files)
return f'| {fmt}'
return ''


def gen(outfile: str, desc: Description, extra_rules: T.List[Rule]) -> int:
with open(outfile, 'w', encoding='utf-8') as f:
f.write('ninja_dyndep_version = 1\n\n')

for obj, provides, requires in process_rules(desc['rules'], extra_rules):
ins = format(requires)
out = format(provides)
f.write(f'build {quote(obj)} {out}: dyndep {ins}\n\n')

return 0


def run(args: T.List[str]) -> int:
assert len(args) >= 2, 'got wrong number of arguments!'
outfile, jsonfile, *jsondeps = args
with open(jsonfile, 'r', encoding='utf-8') as f:
desc: Description = json.load(f)

# All rules, necessary for fulfilling across TU and target boundaries
rules = desc['rules'].copy()
for dep in jsondeps:
with open(dep, encoding='utf-8') as f:
d: Description = json.load(f)
rules.extend(d['rules'])

return gen(outfile, desc, rules)
123 changes: 79 additions & 44 deletions mesonbuild/scripts/depscan.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,18 +15,56 @@

from __future__ import annotations
import collections
import json
import os
import pathlib
import pickle
import re
import typing as T

from ..backend.ninjabackend import ninja_quote

if T.TYPE_CHECKING:
from typing_extensions import Literal
from typing_extensions import Literal, TypedDict, NotRequired
from ..backend.ninjabackend import TargetDependencyScannerInfo

Require = TypedDict(
'Require',
{
'logical-name': str,
'compiled-module-path': NotRequired[str],
'source-path': NotRequired[str],
'unique-on-source-path': NotRequired[bool],
'lookup-method': NotRequired[Literal['by-name', 'include-angle', 'include-quote']]
},
)

Provide = TypedDict(
'Provide',
{
'logical-name': str,
'compiled-module-path': NotRequired[str],
'source-path': NotRequired[str],
'unique-on-source-path': NotRequired[bool],
'is-interface': NotRequired[bool],
},
)

Rule = TypedDict(
'Rule',
{
'primary-output': NotRequired[str],
'outputs': NotRequired[T.List[str]],
'provides': NotRequired[T.List[Provide]],
'requires': NotRequired[T.List[Require]],
}
)

class Description(TypedDict):

version: int
revision: int
rules: T.List[Rule]


CPP_IMPORT_RE = re.compile(r'\w*import ([a-zA-Z0-9]+);')
CPP_EXPORT_RE = re.compile(r'\w*export module ([a-zA-Z0-9]+);')

Expand Down Expand Up @@ -133,47 +171,44 @@ def module_name_for(self, src: str, lang: Literal['cpp', 'fortran']) -> str:
def scan(self) -> int:
for s, lang in self.sources:
self.scan_file(s, lang)
with open(self.outfile, 'w', encoding='utf-8') as ofile:
ofile.write('ninja_dyndep_version = 1\n')
for src, lang in self.sources:
objfilename = self.target_data.source2object[src]
mods_and_submods_needed = []
module_files_generated = []
module_files_needed = []
if src in self.sources_with_exports:
module_files_generated.append(self.module_name_for(src, lang))
if src in self.needs:
for modname in self.needs[src]:
if modname not in self.provided_by:
# Nothing provides this module, we assume that it
# comes from a dependency library somewhere and is
# already built by the time this compilation starts.
pass
else:
mods_and_submods_needed.append(modname)

for modname in mods_and_submods_needed:
provider_src = self.provided_by[modname]
provider_modfile = self.module_name_for(provider_src, lang)
# Prune self-dependencies
if provider_src != src:
module_files_needed.append(provider_modfile)

quoted_objfilename = ninja_quote(objfilename, True)
quoted_module_files_generated = [ninja_quote(x, True) for x in module_files_generated]
quoted_module_files_needed = [ninja_quote(x, True) for x in module_files_needed]
if quoted_module_files_generated:
mod_gen = '| ' + ' '.join(quoted_module_files_generated)
else:
mod_gen = ''
if quoted_module_files_needed:
mod_dep = '| ' + ' '.join(quoted_module_files_needed)
else:
mod_dep = ''
build_line = 'build {} {}: dyndep {}'.format(quoted_objfilename,
mod_gen,
mod_dep)
ofile.write(build_line + '\n')
description: Description = {
'version': 1,
'revision': 0,
'rules': [],
}
for src, lang in self.sources:
rule: Rule = {
'primary-output': self.target_data.source2object[src],
'requires': [],
'provides': [],
}
if src in self.sources_with_exports:
rule['outputs'] = [self.module_name_for(src, lang)]
if src in self.needs:
for modname in self.needs[src]:
provider_src = self.provided_by.get(modname)
if provider_src == src:
continue
rule['requires'].append({
'logical-name': modname,
})
if provider_src:
rule['requires'][-1].update({
'source-path': provider_src,
'compiled-module-path': self.module_name_for(provider_src, lang),
})
if src in self.exports:
modname = self.exports[src]
rule['provides'].append({
'logical-name': modname,
'source-path': src,
'compiled-module-path': self.module_name_for(src, lang),
})
description['rules'].append(rule)

with open(self.outfile, 'w', encoding='utf-8') as f:
json.dump(description, f)

return 0

def run(args: T.List[str]) -> int:
Expand Down

0 comments on commit 282c0b1

Please sign in to comment.